#ifdef MESHTASTIC_INCLUDE_INKHUD

#include "./MenuApplet.h"

#include "DisplayFormatters.h"
#include "GPS.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "MessageStore.h"
#include "RTC.h"
#include "Router.h"
#include "airtime.h"
#include "graphics/niche/Utils/FlashData.h"
#include "main.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "power.h"
#include <RadioLibInterface.h>
#include <target_specific.h>
#if defined(ARCH_ESP32) && HAS_WIFI
#include "mesh/wifi/WiFiAPClient.h"
#include <WiFi.h>
#include <esp_wifi.h>
#endif

using namespace NicheGraphics;

static constexpr uint8_t MENU_TIMEOUT_SEC = 60; // How many seconds before menu auto-closes

// Options for the "Recents" menu
// These are offered to users as possible values for settings.recentlyActiveSeconds
static constexpr uint8_t RECENTS_OPTIONS_MINUTES[] = {2, 5, 10, 30, 60, 120};

struct DisplayTimeoutOption {
    uint32_t seconds;
    const char *label;
};

static constexpr DisplayTimeoutOption DISPLAY_TIMEOUT_OPTIONS[] = {
    {0, "Forever"},      {30, "30 secs"},     {60, "1 min"},     {5 * 60, "5 min"},
    {15 * 60, "15 min"}, {30 * 60, "30 min"}, {60 * 60, "1 hr"},
};

struct PositionPrecisionOption {
    uint8_t value; // proto value
    const char *metric;
    const char *imperial;
};

static constexpr PositionPrecisionOption POSITION_PRECISION_OPTIONS[] = {
    {32, "Precise", "Precise"}, {19, "50 m", "150 ft"},  {18, "90 m", "300 ft"},   {17, "200 m", "600 ft"},
    {16, "350 m", "0.2 mi"},    {15, "700 m", "0.5 mi"}, {14, "1.5 km", "0.9 mi"}, {13, "2.9 km", "1.8 mi"},
    {12, "5.8 km", "3.6 mi"},   {11, "12 km", "7.3 mi"}, {10, "23 km", "15 mi"},
};

static const char *getDisplayTimeoutLabel(uint32_t timeoutSeconds)
{
    constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
    for (uint8_t i = 0; i < optionCount; i++) {
        if (DISPLAY_TIMEOUT_OPTIONS[i].seconds == timeoutSeconds) {
            return DISPLAY_TIMEOUT_OPTIONS[i].label;
        }
    }

    return "Custom";
}

static bool supportsFreeTextKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings)
{
    return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider());
}

static bool useTouchFriendlyMenuLayout(const InkHUD::InkHUD *inkhud)
{
    return inkhud != nullptr && inkhud->hasTouchEnabledProvider();
}

static uint16_t getMenuItemHeightPx(const InkHUD::InkHUD *inkhud)
{
    const bool touchFriendly = useTouchFriendlyMenuLayout(inkhud);
    const uint16_t lineH = touchFriendly ? InkHUD::Applet::fontMedium.lineHeight() : InkHUD::Applet::fontSmall.lineHeight();
    const float rowScale = touchFriendly ? 1.9f : 1.6f;
    uint16_t itemH = (uint16_t)(lineH * rowScale);
    if (itemH == 0) {
        itemH = 1;
    }
    return itemH;
}

#if defined(T5_S3_EPAPER_PRO)
namespace
{
static constexpr uint32_t T5_BACKLIGHT_PREFS_VERSION = 1;

struct T5BacklightPrefs {
    uint32_t version = T5_BACKLIGHT_PREFS_VERSION;
    bool keepOn = true;
};

T5BacklightPrefs t5BacklightPrefs;
bool t5BacklightPrefsLoaded = false;

bool loadT5BacklightKeepOn()
{
    if (!t5BacklightPrefsLoaded) {
        T5BacklightPrefs loaded;
        const bool ok = FlashData<T5BacklightPrefs>::load(&loaded, "t5_backlight");
        if (ok && loaded.version == T5_BACKLIGHT_PREFS_VERSION) {
            t5BacklightPrefs = loaded;
        }
        t5BacklightPrefsLoaded = true;
    }

    return t5BacklightPrefs.keepOn;
}

void saveT5BacklightKeepOn(bool keepOn)
{
    loadT5BacklightKeepOn();
    t5BacklightPrefs.version = T5_BACKLIGHT_PREFS_VERSION;
    t5BacklightPrefs.keepOn = keepOn;
    FlashData<T5BacklightPrefs>::save(&t5BacklightPrefs, "t5_backlight");
}
} // namespace
#endif

InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
{
    // No timer tasks at boot
    OSThread::disable();

    // Note: don't get instance if we're not actually using the backlight,
    // or else you will unintentionally instantiate it
    if (settings->optionalMenuItems.backlight) {
#if defined(T5_S3_EPAPER_PRO)
        t5BacklightSetUserEnabled(loadT5BacklightKeepOn());
#else
        backlight = Drivers::LatchingBacklight::getInstance();
#endif
    }

    // Initialize the Canned Message store
    // This is a shared nicheGraphics component
    // - handles loading & parsing the canned messages
    // - handles setting / getting of canned messages via apps (Client API Admin Messages)
    cm.store = CannedMessageStore::getInstance();
}

void InkHUD::MenuApplet::onForeground()
{
    // We do need this before we render, but we can optimize by just calculating it once now
    systemInfoPanelHeight = getSystemInfoPanelHeight();

    // Force Region page ONLY when explicitly requested (one-shot)
    if (inkhud->forceRegionMenu) {

        inkhud->forceRegionMenu = false; // consume one-shot flag
        showPage(MenuPage::REGION);

    } else {
        showPage(MenuPage::ROOT);
    }

    // If device has a backlight which isn't controlled by aux button:
    // backlight on always when menu opens.
    // Courtesy to T-Echo users who removed the capacitive touch button
    if (settings->optionalMenuItems.backlight) {
#if !defined(T5_S3_EPAPER_PRO)
        assert(backlight);
        if (!backlight->isOn())
            backlight->peek();
#endif
    }

    // Prevent user applets requesting update while menu is open
    // Handle button input with this applet
    SystemApplet::lockRequests = true;
    SystemApplet::handleInput = true;

    // Begin the auto-close timeout
    OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
    OSThread::enabled = true;

    freeTextMode = false;

    // Upgrade the refresh to FAST, for guaranteed responsiveness
    inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}

void InkHUD::MenuApplet::onBackground()
{
    // Discard any data we generated while selecting a canned message
    // Frees heap mem
    freeCannedMessageResources();

    // If device has a backlight which isn't controlled by aux button:
    // Item in options submenu allows keeping backlight on after menu is closed
    // If this item is deselected we will turn backlight off again, now that menu is closing
    if (settings->optionalMenuItems.backlight) {
#if !defined(T5_S3_EPAPER_PRO)
        assert(backlight);
        if (!backlight->isLatched())
            backlight->off();
#endif
    }

    // Stop the auto-timeout
    OSThread::disable();

    // Resume normal rendering and button behavior of user applets
    SystemApplet::lockRequests = false;
    SystemApplet::handleInput = false;

    handleFreeText = false;

    // Restore the user applet whose tile we borrowed
    if (borrowedTileOwner)
        borrowedTileOwner->bringToForeground();
    Tile *t = getTile();
    t->assignApplet(borrowedTileOwner); // Break our link with the tile, (and relink it with real owner, if it had one)
    borrowedTileOwner = nullptr;

    // Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background
    // We're only updating here to upgrade from UNSPECIFIED to FAST, to ensure responsiveness when exiting menu
    inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}

// Open the menu
// Parameter specifies which user-tile the menu will use
// The user applet originally on this tile will be restored when the menu closes
void InkHUD::MenuApplet::show(Tile *t)
{
    // Remember who *really* owns this tile
    borrowedTileOwner = t->getAssignedApplet();

    // Hide the owner, if it is a valid applet
    if (borrowedTileOwner)
        borrowedTileOwner->sendToBackground();

    // Break the owner's link with tile
    // Relink it to menu applet
    t->assignApplet(this);

    // Show menu
    bringToForeground();
}

// Auto-exit the menu applet after a period of inactivity
// The values shown on the root menu are only a snapshot: they are not re-rendered while the menu remains open.
// By exiting the menu, we prevent users mistakenly believing that the data will update.
int32_t InkHUD::MenuApplet::runOnce()
{
    // runOnce's interval is pushed back when a button is pressed
    // If we do actually run, it means no button input occurred within MENU_TIMEOUT_SEC,
    // so we close the menu.
    showPage(EXIT);

    // Timer should disable after firing
    // This is redundant, as onBackground() will also disable
    return OSThread::disable();
}

// Storage for the dynamically-built region preset list — populated in showPage(NODE_CONFIG_PRESET)
static constexpr uint8_t MAX_REGION_PRESETS = 16;
static meshtastic_Config_LoRaConfig_ModemPreset regionPresets[MAX_REGION_PRESETS];
static uint8_t regionPresetCount = 0;

static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
{
    if (config.lora.region == region)
        return;

    config.lora.region = region;

    auto changes = SEGMENT_CONFIG;

#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
    if (crypto) {
        crypto->ensurePkiKeys(config.security, owner);
    }
#endif

    config.lora.tx_enabled = true;

    initRegion();

    if (myRegion && getEffectiveDutyCycle() < 100) {
        config.lora.ignore_mqtt = true;
    }

    if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
        snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
        changes |= SEGMENT_MODULECONFIG;
    }
    // Notify UI that changes are being applied
    InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
    service->reloadConfig(changes);

    rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
}

static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role)
{
    if (config.device.role == role)
        return;

    config.device.role = role;

    nodeDB->saveToDisk(SEGMENT_CONFIG);

    service->reloadConfig(SEGMENT_CONFIG);

    // Notify UI that changes are being applied
    InkHUD::InkHUD::getInstance()->notifyApplyingChanges();

    rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
}

static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset)
{
    if (config.lora.modem_preset == preset)
        return;

    config.lora.use_preset = true;
    config.lora.modem_preset = preset;

    nodeDB->saveToDisk(SEGMENT_CONFIG);
    service->reloadConfig(SEGMENT_CONFIG);

    // Notify UI that changes are being applied
    InkHUD::InkHUD::getInstance()->notifyApplyingChanges();

    rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
}

static const char *getTimezoneLabelFromValue(const char *tzdef)
{
    if (!tzdef || !*tzdef)
        return "Unset";

    // Must match TIMEZONE menu entries
    if (strcmp(tzdef, "HST10") == 0)
        return "US/Hawaii";
    if (strcmp(tzdef, "AKST9AKDT,M3.2.0,M11.1.0") == 0)
        return "US/Alaska";
    if (strcmp(tzdef, "PST8PDT,M3.2.0,M11.1.0") == 0)
        return "US/Pacific";
    if (strcmp(tzdef, "MST7") == 0)
        return "US/Arizona";
    if (strcmp(tzdef, "MST7MDT,M3.2.0,M11.1.0") == 0)
        return "US/Mountain";
    if (strcmp(tzdef, "CST6CDT,M3.2.0,M11.1.0") == 0)
        return "US/Central";
    if (strcmp(tzdef, "EST5EDT,M3.2.0,M11.1.0") == 0)
        return "US/Eastern";
    if (strcmp(tzdef, "BRT3") == 0)
        return "BR/Brasilia";
    if (strcmp(tzdef, "UTC0") == 0)
        return "UTC";
    if (strcmp(tzdef, "GMT0BST,M3.5.0/1,M10.5.0") == 0)
        return "EU/Western";
    if (strcmp(tzdef, "CET-1CEST,M3.5.0,M10.5.0/3") == 0)
        return "EU/Central";
    if (strcmp(tzdef, "EET-2EEST,M3.5.0/3,M10.5.0/4") == 0)
        return "EU/Eastern";
    if (strcmp(tzdef, "IST-5:30") == 0)
        return "Asia/Kolkata";
    if (strcmp(tzdef, "HKT-8") == 0)
        return "Asia/Hong Kong";
    if (strcmp(tzdef, "AWST-8") == 0)
        return "AU/AWST";
    if (strcmp(tzdef, "ACST-9:30ACDT,M10.1.0,M4.1.0/3") == 0)
        return "AU/ACST";
    if (strcmp(tzdef, "AEST-10AEDT,M10.1.0,M4.1.0/3") == 0)
        return "AU/AEST";
    if (strcmp(tzdef, "NZST-12NZDT,M9.5.0,M4.1.0/3") == 0)
        return "Pacific/NZ";

    return tzdef; // fallback for unknown/custom values
}

static void applyTimezone(const char *tz)
{
    if (!tz || strcmp(config.device.tzdef, tz) == 0)
        return;

    strncpy(config.device.tzdef, tz, sizeof(config.device.tzdef));
    config.device.tzdef[sizeof(config.device.tzdef) - 1] = '\0';

    setenv("TZ", config.device.tzdef, 1);

    nodeDB->saveToDisk(SEGMENT_CONFIG);
    service->reloadConfig(SEGMENT_CONFIG);
}

// Perform action for a menu item, then change page
// Behaviors for MenuActions are defined here
void InkHUD::MenuApplet::execute(MenuItem item)
{
    // Perform an action
    // ------------------
    switch (item.action) {

    // Open a submenu without performing any action
    // Also handles exit
    case NO_ACTION:
        if (currentPage == MenuPage::NODE_CONFIG_CHANNELS && item.nextPage == MenuPage::NODE_CONFIG_CHANNEL_DETAIL) {

            // cursor - 1 because index 0 is "Back"
            selectedChannelIndex = cursor - 1;
        }
        break;

    case NEXT_TILE:
        inkhud->nextTile();
        // Unselect menu item after tile change
        cursorShown = false;
        cursor = 0;
        break;

    case SEND_PING:
        service->refreshLocalMeshNode();
        service->trySendPosition(NODENUM_BROADCAST, true);

        // Force the next refresh to use FULL, to protect the display, as some users will probably spam this button
        inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL);
        break;

    case FREE_TEXT:
        OSThread::enabled = false;
        handleFreeText = true;
        cm.freeTextItem.rawText.erase(); // clear the previous freetext message
        freeTextMode = true;             // render input field instead of normal menu
        if (supportsFreeTextKeyboard(inkhud, settings))
            inkhud->openKeyboard();
        break;

    case STORE_CANNEDMESSAGE_SELECTION: {
        const uint8_t prefixItems = supportsFreeTextKeyboard(inkhud, settings) ? 2 : 1;
        cm.selectedMessageItem = &cm.messageItems.at(cursor - prefixItems);
    } break;

    case SEND_CANNEDMESSAGE:
        cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
        // send selected message
        sendText(cm.selectedRecipientItem->dest, cm.selectedRecipientItem->channelIndex, cm.selectedMessageItem->rawText.c_str());
        inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL); // Next refresh should be FULL. Lots of button pressing to get here
        break;

    case ROTATE:
        inkhud->rotate();
        break;

    case ALIGN_JOYSTICK:
        inkhud->openAlignStick();
        break;

    case LAYOUT:
        // Todo: smarter incrementing of tile count
        settings->userTiles.count++;

        if (settings->userTiles.count == 3) // Skip 3 tiles: not done yet
            settings->userTiles.count++;

        if (settings->userTiles.count > settings->userTiles.maxCount) // Loop around if tile count now too high
            settings->userTiles.count = 1;

        inkhud->updateLayout();
        break;

    case TOGGLE_APPLET:
        if (item.checkState) {
            *item.checkState = !(*item.checkState);
            inkhud->updateAppletSelection();
        }
        break;

    case TOGGLE_AUTOSHOW_APPLET:
        // Toggle settings.userApplets.autoshow[] value, via MenuItem::checkState pointer set in populateAutoshowPage()
        if (item.checkState) {
            *item.checkState = !(*item.checkState);
        }
        break;

    case TOGGLE_NOTIFICATIONS:
        if (item.checkState) {
            *item.checkState = !(*item.checkState);
        }
        break;

    case TOGGLE_INVERT_COLOR:
        if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED)
            config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT;
        else
            config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_INVERTED;

        nodeDB->saveToDisk(SEGMENT_CONFIG);
        break;

    case SET_RECENTS: {
        // cursor - 1 because index 0 is "Back"
        const uint8_t index = cursor - 1;
        constexpr uint8_t optionCount = sizeof(RECENTS_OPTIONS_MINUTES) / sizeof(RECENTS_OPTIONS_MINUTES[0]);
        assert(index < optionCount);
        settings->recentlyActiveSeconds = RECENTS_OPTIONS_MINUTES[index] * 60;
        break;
    }

    case SHUTDOWN:
        LOG_INFO("Shutting down from menu");
        shutdownAtMsec = millis();
        // Menu is then sent to background via onShutdown
        break;

    case TOGGLE_BATTERY_ICON:
        inkhud->toggleBatteryIcon();
        break;

    case TOGGLE_BACKLIGHT:
        // Note: backlight is already on in this situation.
        // This toggle controls whether it should remain on when menu closes.
#if defined(T5_S3_EPAPER_PRO)
    {
        const bool keepOn = !t5BacklightIsUserEnabled();
        t5BacklightSetUserEnabled(keepOn);
        saveT5BacklightKeepOn(keepOn);
        if (item.checkState)
            *(item.checkState) = keepOn;
    }
#else
        if (!backlight)
            backlight = Drivers::LatchingBacklight::getInstance();
        if (backlight->isLatched())
            backlight->off();
        else
            backlight->latch();
        if (item.checkState)
            *(item.checkState) = backlight->isLatched();
#endif
    break;

    case TOGGLE_12H_CLOCK:
        config.display.use_12h_clock = !config.display.use_12h_clock;
        nodeDB->saveToDisk(SEGMENT_CONFIG);
        break;

    case TOGGLE_GPS:
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
        if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED) {
            config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
        } else if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
            config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED;
        } else {
            // NOT_PRESENT do nothing
            break;
        }
        nodeDB->saveToDisk(SEGMENT_CONFIG);
        service->reloadConfig(SEGMENT_CONFIG);
#endif
        break;

    case ENABLE_BLUETOOTH:
        // This helps users recover from a bad wifi config
        LOG_INFO("Enabling Bluetooth");
        config.network.wifi_enabled = false;
        config.bluetooth.enabled = true;
        nodeDB->saveToDisk(SEGMENT_CONFIG);
        InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
        rebootAtMsec = millis() + 2000;
        break;

        // Power / Network (ESP32-only)
#if defined(ARCH_ESP32)
    case TOGGLE_POWER_SAVE:
        config.power.is_power_saving = !config.power.is_power_saving;
        nodeDB->saveToDisk(SEGMENT_CONFIG);
        InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
        rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
        break;

    case TOGGLE_WIFI:
        config.network.wifi_enabled = !config.network.wifi_enabled;

        if (config.network.wifi_enabled) {
            // Switch behavior: WiFi ON forces Bluetooth OFF
            config.bluetooth.enabled = false;
        }

        nodeDB->saveToDisk(SEGMENT_CONFIG);
        InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
        rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
        break;
#endif
    // ADC Calibration
    case CALIBRATE_ADC: {
        // Read current measured voltage
        float measuredV = powerStatus->getBatteryVoltageMv() / 1000.0f;

        // Sanity check
        if (measuredV < 3.0f || measuredV > 4.5f) {
            LOG_WARN("ADC calibration aborted, unreasonable voltage: %.2fV", measuredV);
            break;
        }

        // Determine the base multiplier currently in effect
        float baseMult = 0.0f;

        if (config.power.adc_multiplier_override > 0.0f) {
            baseMult = config.power.adc_multiplier_override;
        }
#ifdef ADC_MULTIPLIER
        else {
            baseMult = ADC_MULTIPLIER;
        }
#endif

        if (baseMult <= 0.0f) {
            LOG_WARN("ADC calibration failed: no base multiplier");
            break;
        }

        // Target voltage considered 100% by UI
        constexpr float TARGET_VOLTAGE = 4.19f;

        // Calculate new multiplier
        float newMult = baseMult * (TARGET_VOLTAGE / measuredV);

        config.power.adc_multiplier_override = newMult;

        nodeDB->saveToDisk(SEGMENT_CONFIG);

        LOG_INFO("ADC calibrated: measured=%.3fV base=%.4f new=%.4f", measuredV, baseMult, newMult);

        break;
    }

    // Display
    case SET_DISPLAY_TIMEOUT: {
        // cursor - 1 because index 0 is "Back"
        const uint8_t index = cursor - 1;
        constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
        if (index < optionCount) {
            config.display.screen_on_secs = DISPLAY_TIMEOUT_OPTIONS[index].seconds;
            nodeDB->saveToDisk(SEGMENT_CONFIG);
        }
        break;
    }

    case TOGGLE_DISPLAY_UNITS:
        if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
            config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC;
        else
            config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL;

        nodeDB->saveToDisk(SEGMENT_CONFIG);
        break;

    // Bluetooth
    case TOGGLE_BLUETOOTH:
        config.bluetooth.enabled = !config.bluetooth.enabled;

        if (config.bluetooth.enabled) {
            // Switch behavior: Bluetooth ON forces WiFi OFF
            config.network.wifi_enabled = false;
        }

        nodeDB->saveToDisk(SEGMENT_CONFIG);
        InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
        rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
        break;

    case TOGGLE_BLUETOOTH_PAIR_MODE:
        config.bluetooth.fixed_pin = !config.bluetooth.fixed_pin;
        nodeDB->saveToDisk(SEGMENT_CONFIG);
        break;

    // Regions
    case SET_REGION_US:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
        break;

    case SET_REGION_EU_868:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
        break;

    case SET_REGION_EU_433:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_433);
        break;

    case SET_REGION_CN:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_CN);
        break;

    case SET_REGION_JP:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_JP);
        break;

    case SET_REGION_ANZ:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ANZ);
        break;
    case SET_REGION_KR:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KR);
        break;

    case SET_REGION_TW:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_TW);
        break;

    case SET_REGION_RU:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_RU);
        break;

    case SET_REGION_IN:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_IN);
        break;

    case SET_REGION_NZ_865:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_NZ_865);
        break;

    case SET_REGION_TH:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_TH);
        break;

    case SET_REGION_LORA_24:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
        break;

    case SET_REGION_UA_433:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_433);
        break;

    case SET_REGION_UA_868:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_868);
        break;

    case SET_REGION_MY_433:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_MY_433);
        break;

    case SET_REGION_MY_919:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_MY_919);
        break;

    case SET_REGION_SG_923:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_SG_923);
        break;

    case SET_REGION_PH_433:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_433);
        break;

    case SET_REGION_PH_868:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_868);
        break;

    case SET_REGION_PH_915:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_915);
        break;

    case SET_REGION_ANZ_433:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ANZ_433);
        break;

    case SET_REGION_KZ_433:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KZ_433);
        break;

    case SET_REGION_KZ_863:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KZ_863);
        break;

    case SET_REGION_NP_865:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_NP_865);
        break;

    case SET_REGION_BR_902:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_BR_902);
        break;

    case SET_REGION_EU_866:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
        break;

    case SET_REGION_NARROW_868:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868);
        break;

    case SET_REGION_ITU1_2M:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M);
        break;

    case SET_REGION_ITU2_2M:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M);
        break;

    case SET_REGION_ITU3_2M:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M);
        break;

    case SET_REGION_ITU2_125CM:
        applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM);
        break;

    // Roles
    case SET_ROLE_CLIENT:
        applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT);
        break;

    case SET_ROLE_CLIENT_MUTE:
        applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE);
        break;

    case SET_ROLE_ROUTER:
        applyDeviceRole(meshtastic_Config_DeviceConfig_Role_ROUTER);
        break;

    case SET_ROLE_REPEATER:
        applyDeviceRole(meshtastic_Config_DeviceConfig_Role_REPEATER);
        break;

    // Presets
    case SET_PRESET_LONG_SLOW:
        applyLoRaPreset(PRESET(LONG_SLOW));
        break;

    case SET_PRESET_LONG_MODERATE:
        applyLoRaPreset(PRESET(LONG_MODERATE));
        break;

    case SET_PRESET_LONG_FAST:
        applyLoRaPreset(PRESET(LONG_FAST));
        break;

    case SET_PRESET_MEDIUM_SLOW:
        applyLoRaPreset(PRESET(MEDIUM_SLOW));
        break;

    case SET_PRESET_MEDIUM_FAST:
        applyLoRaPreset(PRESET(MEDIUM_FAST));
        break;

    case SET_PRESET_SHORT_SLOW:
        applyLoRaPreset(PRESET(SHORT_SLOW));
        break;

    case SET_PRESET_SHORT_FAST:
        applyLoRaPreset(PRESET(SHORT_FAST));
        break;

    case SET_PRESET_SHORT_TURBO:
        applyLoRaPreset(PRESET(SHORT_TURBO));
        break;

    case SET_PRESET_NARROW_SLOW:
        applyLoRaPreset(PRESET(NARROW_SLOW));
        break;

    case SET_PRESET_NARROW_FAST:
        applyLoRaPreset(PRESET(NARROW_FAST));
        break;

    case SET_PRESET_TINY_SLOW:
        applyLoRaPreset(PRESET(TINY_SLOW));
        break;

    case SET_PRESET_TINY_FAST:
        applyLoRaPreset(PRESET(TINY_FAST));
        break;

    case SET_PRESET_FROM_REGION: {
        // cursor - 1 because index 0 is "Back"
        const uint8_t index = cursor - 1;
        if (index < regionPresetCount) {
            applyLoRaPreset(regionPresets[index]);
        }
        break;
    }

    // Timezones
    case SET_TZ_US_HAWAII:
        applyTimezone("HST10");
        break;

    case SET_TZ_US_ALASKA:
        applyTimezone("AKST9AKDT,M3.2.0,M11.1.0");
        break;

    case SET_TZ_US_PACIFIC:
        applyTimezone("PST8PDT,M3.2.0,M11.1.0");
        break;

    case SET_TZ_US_ARIZONA:
        applyTimezone("MST7");
        break;

    case SET_TZ_US_MOUNTAIN:
        applyTimezone("MST7MDT,M3.2.0,M11.1.0");
        break;

    case SET_TZ_US_CENTRAL:
        applyTimezone("CST6CDT,M3.2.0,M11.1.0");
        break;

    case SET_TZ_US_EASTERN:
        applyTimezone("EST5EDT,M3.2.0,M11.1.0");
        break;

    case SET_TZ_BR_BRAZILIA:
        applyTimezone("BRT3");
        break;

    case SET_TZ_UTC:
        applyTimezone("UTC0");
        break;

    case SET_TZ_EU_WESTERN:
        applyTimezone("GMT0BST,M3.5.0/1,M10.5.0");
        break;

    case SET_TZ_EU_CENTRAL:
        applyTimezone("CET-1CEST,M3.5.0,M10.5.0/3");
        break;

    case SET_TZ_EU_EASTERN:
        applyTimezone("EET-2EEST,M3.5.0/3,M10.5.0/4");
        break;

    case SET_TZ_ASIA_KOLKATA:
        applyTimezone("IST-5:30");
        break;

    case SET_TZ_ASIA_HONG_KONG:
        applyTimezone("HKT-8");
        break;

    case SET_TZ_AU_AWST:
        applyTimezone("AWST-8");
        break;

    case SET_TZ_AU_ACST:
        applyTimezone("ACST-9:30ACDT,M10.1.0,M4.1.0/3");
        break;

    case SET_TZ_AU_AEST:
        applyTimezone("AEST-10AEDT,M10.1.0,M4.1.0/3");
        break;

    case SET_TZ_PACIFIC_NZ:
        applyTimezone("NZST-12NZDT,M9.5.0,M4.1.0/3");
        break;

    // Channels
    case TOGGLE_CHANNEL_UPLINK: {
        auto &ch = channels.getByIndex(selectedChannelIndex);
        ch.settings.uplink_enabled = !ch.settings.uplink_enabled;
        nodeDB->saveToDisk(SEGMENT_CHANNELS);
        service->reloadConfig(SEGMENT_CHANNELS);
        break;
    }

    case TOGGLE_CHANNEL_DOWNLINK: {
        auto &ch = channels.getByIndex(selectedChannelIndex);
        ch.settings.downlink_enabled = !ch.settings.downlink_enabled;
        nodeDB->saveToDisk(SEGMENT_CHANNELS);
        service->reloadConfig(SEGMENT_CHANNELS);
        break;
    }

    case TOGGLE_CHANNEL_POSITION: {
        auto &ch = channels.getByIndex(selectedChannelIndex);

        if (!ch.settings.has_module_settings)
            ch.settings.has_module_settings = true;

        if (ch.settings.module_settings.position_precision > 0)
            ch.settings.module_settings.position_precision = 0;
        else
            ch.settings.module_settings.position_precision = 13; // default

        nodeDB->saveToDisk(SEGMENT_CHANNELS);
        service->reloadConfig(SEGMENT_CHANNELS);
        break;
    }

    case SET_CHANNEL_PRECISION: {
        auto &ch = channels.getByIndex(selectedChannelIndex);

        if (!ch.settings.has_module_settings)
            ch.settings.has_module_settings = true;

        // Cursor - 1 because of "Back"
        uint8_t index = cursor - 1;

        constexpr uint8_t optionCount = sizeof(POSITION_PRECISION_OPTIONS) / sizeof(POSITION_PRECISION_OPTIONS[0]);

        if (index < optionCount) {
            ch.settings.module_settings.position_precision = POSITION_PRECISION_OPTIONS[index].value;
        }

        nodeDB->saveToDisk(SEGMENT_CHANNELS);
        service->reloadConfig(SEGMENT_CHANNELS);
        break;
    }

    case RESET_NODEDB_ALL:
        InkHUD::getInstance()->notifyApplyingChanges();
        nodeDB->resetNodes();
        rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
        break;

    case RESET_NODEDB_KEEP_FAVORITES:
        InkHUD::getInstance()->notifyApplyingChanges();
        nodeDB->resetNodes(1);
        rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
        break;

    case WIPE_MESSAGES_ALL:
        LOG_INFO("Wiping all messages from menu");
        messageStore.clearAllMessages();
        inkhud->persistence->loadLatestMessage();
        inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true);
        break;

    default:
        LOG_WARN("Action not implemented");
    }

    // Move to next page, as defined for the MenuItem
    showPage(item.nextPage);
}

// Display a new page of MenuItems
// May reload same page, or exit menu applet entirely
// Fills the MenuApplet::items vector
void InkHUD::MenuApplet::showPage(MenuPage page)
{
    items.clear();
    items.shrink_to_fit();
    nodeConfigLabels.clear();

    switch (page) {
    case ROOT:
        previousPage = MenuPage::EXIT;
        // Optional: next applet
        if (settings->optionalMenuItems.nextTile && settings->userTiles.count > 1)
            items.push_back(MenuItem("Next Tile", MenuAction::NEXT_TILE, MenuPage::ROOT)); // Only if multiple applets shown

        items.push_back(MenuItem("Send", MenuPage::SEND));
        items.push_back(MenuItem("Options", MenuPage::OPTIONS));
        // items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO
        items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG));
        items.push_back(MenuItem("Save & Shut Down", MenuAction::SHUTDOWN));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case SEND:
        populateSendPage();
        previousPage = MenuPage::ROOT;
        break;

    case CANNEDMESSAGE_RECIPIENT:
        populateRecipientPage();
        previousPage = MenuPage::SEND;
        break;

    case OPTIONS:
        previousPage = MenuPage::ROOT;
        items.push_back(MenuItem("Back", previousPage));
        // Optional: backlight
        if (settings->optionalMenuItems.backlight) {
#if defined(T5_S3_EPAPER_PRO)
            keepBacklightOn = t5BacklightIsUserEnabled();
#else
            if (!backlight)
                backlight = Drivers::LatchingBacklight::getInstance();
            keepBacklightOn = backlight->isLatched();
#endif
            items.push_back(MenuItem("Keep Backlight On", MenuAction::TOGGLE_BACKLIGHT, MenuPage::OPTIONS, &keepBacklightOn));
        }

        // Options Toggles
        items.push_back(MenuItem("Applets", MenuPage::APPLETS));
        items.push_back(MenuItem("Auto-show", MenuPage::AUTOSHOW));
        items.push_back(MenuItem("Recents Duration", MenuPage::RECENTS));
        if (settings->userTiles.maxCount > 1)
            items.push_back(MenuItem("Layout", MenuAction::LAYOUT, MenuPage::OPTIONS));
        items.push_back(MenuItem("Rotate", MenuAction::ROTATE, MenuPage::OPTIONS));
        if (settings->joystick.enabled && !inkhud->twoWayRocker)
            items.push_back(MenuItem("Align Joystick", MenuAction::ALIGN_JOYSTICK, MenuPage::EXIT));
        items.push_back(MenuItem("Notifications", MenuAction::TOGGLE_NOTIFICATIONS, MenuPage::OPTIONS,
                                 &settings->optionalFeatures.notifications));
        items.push_back(MenuItem("Battery Icon", MenuAction::TOGGLE_BATTERY_ICON, MenuPage::OPTIONS,
                                 &settings->optionalFeatures.batteryIcon));
        invertedColors = (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
        items.push_back(MenuItem("Invert Color", MenuAction::TOGGLE_INVERT_COLOR, MenuPage::OPTIONS, &invertedColors));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case APPLETS:
        previousPage = MenuPage::OPTIONS;
        populateAppletPage(); // must be first
        items.insert(items.begin(), MenuItem("Back", previousPage));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case AUTOSHOW:
        previousPage = MenuPage::OPTIONS;
        populateAutoshowPage(); // must be first
        items.insert(items.begin(), MenuItem("Back", previousPage));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case RECENTS:
        previousPage = MenuPage::OPTIONS;
        populateRecentsPage(); // builds only the options
        items.insert(items.begin(), MenuItem("Back", previousPage));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case NODE_CONFIG:
        previousPage = MenuPage::ROOT;
        items.push_back(MenuItem("Back", previousPage));
        // Radio Config Section
        items.push_back(MenuItem::Header("Radio Config"));
        items.push_back(MenuItem("LoRa", MenuPage::NODE_CONFIG_LORA));
        items.push_back(MenuItem("Channel", MenuPage::NODE_CONFIG_CHANNELS));
        // Device Config Section
        items.push_back(MenuItem::Header("Device Config"));
        items.push_back(MenuItem("Device", MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("Position", MenuPage::NODE_CONFIG_POSITION));
        items.push_back(MenuItem("Power", MenuPage::NODE_CONFIG_POWER));
#if defined(ARCH_ESP32)
        items.push_back(MenuItem("Network", MenuPage::NODE_CONFIG_NETWORK));
#endif
        items.push_back(MenuItem("Display", MenuPage::NODE_CONFIG_DISPLAY));
        items.push_back(MenuItem("Bluetooth", MenuPage::NODE_CONFIG_BLUETOOTH));

        // Administration Section
        items.push_back(MenuItem::Header("Administration"));
        items.push_back(MenuItem("Reset NodeDB", MenuPage::NODE_CONFIG_ADMIN_RESET));
        items.push_back(MenuItem("Wipe Messages", MenuPage::NODE_CONFIG_ADMIN_MESSAGES));

        // Exit
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case NODE_CONFIG_DEVICE: {
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));

        const char *role = DisplayFormatters::getDeviceRole(config.device.role);
        nodeConfigLabels.emplace_back("Role: " + std::string(role));
        items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DEVICE_ROLE));

        const char *tzLabel = getTimezoneLabelFromValue(config.device.tzdef);
        nodeConfigLabels.emplace_back("Timezone: " + std::string(tzLabel));
        items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::TIMEZONE));

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_POSITION: {
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
        const auto mode = config.position.gps_mode;
        if (mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
            items.push_back(MenuItem("GPS None", MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION));
        } else {
            gpsEnabled = (mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED);
            items.push_back(MenuItem("GPS", MenuAction::TOGGLE_GPS, MenuPage::NODE_CONFIG_POSITION, &gpsEnabled));
        }
#endif
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_POWER: {
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));
#if defined(ARCH_ESP32)
        items.push_back(MenuItem("Powersave", MenuAction::TOGGLE_POWER_SAVE, MenuPage::EXIT, &config.power.is_power_saving));
#endif
        // ADC Multiplier
        float effectiveMult = 0.0f;

        // User override always shows if it exists
        if (config.power.adc_multiplier_override > 0.0f) {
            effectiveMult = config.power.adc_multiplier_override;
        }
#ifdef ADC_MULTIPLIER
        else {
            // Fallback to variant defined
            effectiveMult = ADC_MULTIPLIER;
        }
#endif

        // Only show if we actually have a value
        if (effectiveMult > 0.0f) {
            char buf[32];
            snprintf(buf, sizeof(buf), "ADC Mult: %.3f", effectiveMult);
            nodeConfigLabels.emplace_back(buf);

            items.push_back(
                MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POWER_ADC_CAL));
        }

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_POWER_ADC_CAL: {
        previousPage = MenuPage::NODE_CONFIG_POWER;
        items.push_back(MenuItem("Back", previousPage));

        // Instruction text (header-style, non-selectable)
        items.push_back(MenuItem::Header("Run on full charge Only"));

        // Action
        items.push_back(MenuItem("Calibrate ADC", MenuAction::CALIBRATE_ADC, MenuPage::NODE_CONFIG_POWER));

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_NETWORK: {
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));

        const char *wifiLabel = config.network.wifi_enabled ? "WiFi: On" : "WiFi: Off";

        items.push_back(MenuItem(wifiLabel, MenuAction::TOGGLE_WIFI, MenuPage::EXIT));

#if HAS_WIFI && defined(ARCH_ESP32)
        if (config.network.wifi_enabled) {

            // Status
            if (WiFi.status() == WL_CONNECTED) {
                nodeConfigLabels.emplace_back("Status: Connected");
            } else {
                nodeConfigLabels.emplace_back("Status: Not Connected");
            }
            items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));

            // Signal
            if (WiFi.status() == WL_CONNECTED) {
                int rssi = WiFi.RSSI();
                int quality = constrain(2 * (rssi + 100), 0, 100);

                char sigBuf[32];
                snprintf(sigBuf, sizeof(sigBuf), "Signal: %d%%", quality);
                nodeConfigLabels.emplace_back(sigBuf);
                items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));

                char ipBuf[64];
                snprintf(ipBuf, sizeof(ipBuf), "IP: %s", WiFi.localIP().toString().c_str());
                nodeConfigLabels.emplace_back(ipBuf);
                items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
            }

            // SSID
            if (config.network.wifi_ssid && strlen(config.network.wifi_ssid) > 0) {
                std::string ssidLabel = "SSID: ";
                ssidLabel += config.network.wifi_ssid;
                nodeConfigLabels.emplace_back(ssidLabel);
                items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
            }

            // Hostname
            const char *host = WiFi.getHostname();
            if (host && strlen(host) > 0) {
                std::string hostLabel = "Host: ";
                hostLabel += host;
                nodeConfigLabels.emplace_back(hostLabel);
                items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
            }
        }
#endif

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_DISPLAY: {
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));

        items.push_back(MenuItem("12-Hour Clock", MenuAction::TOGGLE_12H_CLOCK, MenuPage::NODE_CONFIG_DISPLAY,
                                 &config.display.use_12h_clock));

        nodeConfigLabels.emplace_back("Screen Timeout: " + std::string(getDisplayTimeoutLabel(config.display.screen_on_secs)));
        items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DISPLAY_TIMEOUT));

        const char *unitsLabel =
            (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? "Units: Imperial" : "Units: Metric";

        items.push_back(MenuItem(unitsLabel, MenuAction::TOGGLE_DISPLAY_UNITS, MenuPage::NODE_CONFIG_DISPLAY));

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_DISPLAY_TIMEOUT:
        previousPage = MenuPage::NODE_CONFIG_DISPLAY;
        items.push_back(MenuItem("Back", previousPage));
        populateDisplayTimeoutPage();
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case NODE_CONFIG_BLUETOOTH: {
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));

        const char *btLabel = config.bluetooth.enabled ? "Bluetooth: On" : "Bluetooth: Off";
        items.push_back(MenuItem(btLabel, MenuAction::TOGGLE_BLUETOOTH, MenuPage::EXIT));

        const char *pairLabel = config.bluetooth.fixed_pin ? "Pair Mode: Fixed" : "Pair Mode: Random";
        items.push_back(MenuItem(pairLabel, MenuAction::TOGGLE_BLUETOOTH_PAIR_MODE, MenuPage::NODE_CONFIG_BLUETOOTH));

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_LORA: {
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));

        const char *region = myRegion ? myRegion->name : "Unset";
        nodeConfigLabels.emplace_back("Region: " + std::string(region));
        items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::REGION));

        const char *preset =
            DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset);
        nodeConfigLabels.emplace_back("Preset: " + std::string(preset));
        items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_PRESET));

        char freqBuf[32];
        float freq = RadioLibInterface::instance->getFreq();
        snprintf(freqBuf, sizeof(freqBuf), "Freq: %.3f MHz", freq);
        nodeConfigLabels.emplace_back(freqBuf);
        items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_LORA));

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_CHANNELS: {
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));

        for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
            const meshtastic_Channel &ch = channels.getByIndex(i);

            if (!ch.has_settings)
                continue;

            if (ch.role == meshtastic_Channel_Role_DISABLED)
                continue;

            std::string label = "#";

            if (ch.role == meshtastic_Channel_Role_PRIMARY) {
                label += "Primary";
            } else if (strlen(ch.settings.name) > 0) {
                label += parse(ch.settings.name);
            } else {
                label += "Channel" + to_string(i + 1);
            }

            nodeConfigLabels.push_back(label);
            items.push_back(
                MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
        }

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_CHANNEL_DETAIL: {
        previousPage = MenuPage::NODE_CONFIG_CHANNELS;
        items.push_back(MenuItem("Back", previousPage));

        meshtastic_Channel &ch = channels.getByIndex(selectedChannelIndex);

        // Name (read-only)
        const char *name = strlen(ch.settings.name) > 0 ? ch.settings.name : "Unnamed";
        nodeConfigLabels.emplace_back("Ch: " + parse(name));
        items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL));

        // Uplink
        items.push_back(MenuItem("Uplink", MenuAction::TOGGLE_CHANNEL_UPLINK, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
                                 &ch.settings.uplink_enabled));

        items.push_back(MenuItem("Downlink", MenuAction::TOGGLE_CHANNEL_DOWNLINK, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
                                 &ch.settings.downlink_enabled));

        // Position
        channelPositionEnabled = ch.settings.has_module_settings && ch.settings.module_settings.position_precision > 0;

        items.push_back(MenuItem("Position", MenuAction::TOGGLE_CHANNEL_POSITION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
                                 &channelPositionEnabled));

        // Precision
        if (channelPositionEnabled) {

            std::string precisionLabel = "Unknown";

            for (const auto &opt : POSITION_PRECISION_OPTIONS) {
                if (opt.value == ch.settings.module_settings.position_precision) {
                    precisionLabel = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
                                         ? opt.imperial
                                         : opt.metric;
                    break;
                }
            }
            nodeConfigLabels.emplace_back("Precision: " + precisionLabel);
            items.push_back(
                MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_PRECISION));
        }

        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_CHANNEL_PRECISION: {
        previousPage = MenuPage::NODE_CONFIG_CHANNEL_DETAIL;
        items.push_back(MenuItem("Back", previousPage));
        const meshtastic_Channel &ch = channels.getByIndex(selectedChannelIndex);
        if (!ch.settings.has_module_settings || ch.settings.module_settings.position_precision == 0) {
            items.push_back(MenuItem("Position is Off", MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
            break;
        }
        constexpr uint8_t optionCount = sizeof(POSITION_PRECISION_OPTIONS) / sizeof(POSITION_PRECISION_OPTIONS[0]);
        for (uint8_t i = 0; i < optionCount; i++) {
            const auto &opt = POSITION_PRECISION_OPTIONS[i];
            const char *label =
                (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? opt.imperial : opt.metric;
            nodeConfigLabels.emplace_back(label);

            items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_CHANNEL_PRECISION,
                                     MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
        }
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case NODE_CONFIG_DEVICE_ROLE: {
        previousPage = MenuPage::NODE_CONFIG_DEVICE;
        items.push_back(MenuItem("Back", previousPage));
        items.push_back(MenuItem("Client", MenuAction::SET_ROLE_CLIENT, MenuPage::EXIT));
        items.push_back(MenuItem("Client Mute", MenuAction::SET_ROLE_CLIENT_MUTE, MenuPage::EXIT));
        items.push_back(MenuItem("Router", MenuAction::SET_ROLE_ROUTER, MenuPage::EXIT));
        items.push_back(MenuItem("Repeater", MenuAction::SET_ROLE_REPEATER, MenuPage::EXIT));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }

    case TIMEZONE:
        previousPage = MenuPage::NODE_CONFIG_DEVICE;
        items.push_back(MenuItem("Back", previousPage));
        items.push_back(MenuItem("US/Hawaii", SET_TZ_US_HAWAII, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("US/Alaska", SET_TZ_US_ALASKA, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("US/Pacific", SET_TZ_US_PACIFIC, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("US/Arizona", SET_TZ_US_ARIZONA, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("US/Mountain", SET_TZ_US_MOUNTAIN, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("US/Central", SET_TZ_US_CENTRAL, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("US/Eastern", SET_TZ_US_EASTERN, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("BR/Brasilia", SET_TZ_BR_BRAZILIA, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("UTC", SET_TZ_UTC, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("EU/Western", SET_TZ_EU_WESTERN, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("EU/Central", SET_TZ_EU_CENTRAL, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("EU/Eastern", SET_TZ_EU_EASTERN, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("Asia/Kolkata", SET_TZ_ASIA_KOLKATA, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("Asia/Hong Kong", SET_TZ_ASIA_HONG_KONG, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("AU/AWST", SET_TZ_AU_AWST, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("AU/ACST", SET_TZ_AU_ACST, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("AU/AEST", SET_TZ_AU_AEST, MenuPage::NODE_CONFIG_DEVICE));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case REGION:
        previousPage = MenuPage::NODE_CONFIG_LORA;
        items.push_back(MenuItem("Back", previousPage));
        items.push_back(MenuItem("US", MenuAction::SET_REGION_US, MenuPage::EXIT));
        items.push_back(MenuItem("EU 868", MenuAction::SET_REGION_EU_868, MenuPage::EXIT));
        items.push_back(MenuItem("EU 433", MenuAction::SET_REGION_EU_433, MenuPage::EXIT));
        items.push_back(MenuItem("EU 866", MenuAction::SET_REGION_EU_866, MenuPage::EXIT));
        items.push_back(MenuItem("EU 868 Narrow", MenuAction::SET_REGION_NARROW_868, MenuPage::EXIT));
        items.push_back(MenuItem("CN", MenuAction::SET_REGION_CN, MenuPage::EXIT));
        items.push_back(MenuItem("JP", MenuAction::SET_REGION_JP, MenuPage::EXIT));
        items.push_back(MenuItem("ANZ", MenuAction::SET_REGION_ANZ, MenuPage::EXIT));
        items.push_back(MenuItem("KR", MenuAction::SET_REGION_KR, MenuPage::EXIT));
        items.push_back(MenuItem("TW", MenuAction::SET_REGION_TW, MenuPage::EXIT));
        items.push_back(MenuItem("RU", MenuAction::SET_REGION_RU, MenuPage::EXIT));
        items.push_back(MenuItem("IN", MenuAction::SET_REGION_IN, MenuPage::EXIT));
        items.push_back(MenuItem("NZ 865", MenuAction::SET_REGION_NZ_865, MenuPage::EXIT));
        items.push_back(MenuItem("TH", MenuAction::SET_REGION_TH, MenuPage::EXIT));
        items.push_back(MenuItem("LoRa 2.4", MenuAction::SET_REGION_LORA_24, MenuPage::EXIT));
        items.push_back(MenuItem("UA 433", MenuAction::SET_REGION_UA_433, MenuPage::EXIT));
        items.push_back(MenuItem("UA 868", MenuAction::SET_REGION_UA_868, MenuPage::EXIT));
        items.push_back(MenuItem("MY 433", MenuAction::SET_REGION_MY_433, MenuPage::EXIT));
        items.push_back(MenuItem("MY 919", MenuAction::SET_REGION_MY_919, MenuPage::EXIT));
        items.push_back(MenuItem("SG 923", MenuAction::SET_REGION_SG_923, MenuPage::EXIT));
        items.push_back(MenuItem("PH 433", MenuAction::SET_REGION_PH_433, MenuPage::EXIT));
        items.push_back(MenuItem("PH 868", MenuAction::SET_REGION_PH_868, MenuPage::EXIT));
        items.push_back(MenuItem("PH 915", MenuAction::SET_REGION_PH_915, MenuPage::EXIT));
        items.push_back(MenuItem("ANZ 433", MenuAction::SET_REGION_ANZ_433, MenuPage::EXIT));
        items.push_back(MenuItem("KZ 433", MenuAction::SET_REGION_KZ_433, MenuPage::EXIT));
        items.push_back(MenuItem("KZ 863", MenuAction::SET_REGION_KZ_863, MenuPage::EXIT));
        items.push_back(MenuItem("NP 865", MenuAction::SET_REGION_NP_865, MenuPage::EXIT));
        items.push_back(MenuItem("BR 902", MenuAction::SET_REGION_BR_902, MenuPage::EXIT));
        items.push_back(MenuItem("ITU1_2M (144-146)", MenuAction::SET_REGION_ITU1_2M, MenuPage::EXIT));
        items.push_back(MenuItem("ITU2_2M (144-148)", MenuAction::SET_REGION_ITU2_2M, MenuPage::EXIT));
        items.push_back(MenuItem("ITU3_2M (144-148)", MenuAction::SET_REGION_ITU3_2M, MenuPage::EXIT));
        items.push_back(MenuItem("ITU2_125CM (220-225)", MenuAction::SET_REGION_ITU2_125CM, MenuPage::EXIT));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case NODE_CONFIG_PRESET: {
        previousPage = MenuPage::NODE_CONFIG_LORA;
        items.push_back(MenuItem("Back", previousPage));
        regionPresetCount = 0;
        if (myRegion && myRegion->profile) {
            const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets();
            size_t numPresets = myRegion->getNumPresets();
            for (size_t i = 0; i < numPresets && regionPresetCount < MAX_REGION_PRESETS; ++i) {
                regionPresets[regionPresetCount++] = presets[i];
                const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true);
                nodeConfigLabels.emplace_back(name);
                items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_PRESET_FROM_REGION, MenuPage::EXIT));
            }
        }
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;
    }
    // Administration Section
    case NODE_CONFIG_ADMIN_RESET:
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));
        items.push_back(MenuItem("Reset All", MenuAction::RESET_NODEDB_ALL, MenuPage::EXIT));
        items.push_back(MenuItem("Keep Favorites Only", MenuAction::RESET_NODEDB_KEEP_FAVORITES, MenuPage::EXIT));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    case NODE_CONFIG_ADMIN_MESSAGES:
        previousPage = MenuPage::NODE_CONFIG;
        items.push_back(MenuItem("Back", previousPage));
        items.push_back(MenuItem("Wipe All Messages", MenuAction::WIPE_MESSAGES_ALL, MenuPage::EXIT));
        items.push_back(MenuItem("Exit", MenuPage::EXIT));
        break;

    // Exit
    case EXIT:
        sendToBackground(); // Menu applet dismissed, allow normal behavior to resume
        break;

    default:
        LOG_WARN("Page not implemented");
    }

    // Reset the cursor, unless reloading same page
    // (or now out-of-bounds)
    if (page != currentPage || cursor >= items.size()) {
        cursor = 0;

        // ROOT menu has special handling: unselected at first, to emphasise the system info panel
        if (page == ROOT)
            cursorShown = false;
    }

    // Ensure cursor never rests on a header
    if (cursorShown) {
        while (cursor < items.size() && items.at(cursor).isHeader) {
            cursor++;
        }
        if (cursor >= items.size())
            cursor = 0;
    }

    // Remember which page we are on now
    currentPage = page;
}

void InkHUD::MenuApplet::onRender(bool full)
{
    // Free text mode draws a text input field and skips the normal rendering
    if (freeTextMode) {
        drawInputField(0, fontSmall.lineHeight(), X(1.0), Y(1.0) - fontSmall.lineHeight() - 1, cm.freeTextItem.rawText);
        return;
    }

    if (items.size() == 0)
        LOG_ERROR("Empty Menu");

    const bool touchFriendlyLayout = useTouchFriendlyMenuLayout(inkhud);
    AppletFont menuItemFont = touchFriendlyLayout ? fontMedium : fontSmall;
    setFont(menuItemFont);

    // Dimensions for the slots where we will draw menuItems
    const float padding = 0.05;
    const uint16_t itemH = getMenuItemHeightPx(inkhud);
    const int16_t selectInsetY = touchFriendlyLayout ? 3 : 2;
    const int16_t itemW = width() - X(padding) - X(padding);
    const int16_t itemL = X(padding);
    const int16_t itemR = X(1 - padding);
    int16_t itemT = 0; // Top (y px of current slot). Incremented as we draw. Adjusted to fit system info panel on ROOT menu.

    // How many full menuItems will fit on screen
    uint8_t slotCount = (height() - itemT) / itemH;

    // System info panel at the top of the menu
    // =========================================

    uint16_t &siH = systemInfoPanelHeight;                   // System info - height. Calculated at onForeground
    const uint8_t slotsObscured = ceilf(siH / (float)itemH); // How many slots are obscured by system info panel

    // System info - top
    // Remain at 0px, until cursor reaches bottom of screen, then begin to scroll off screen.
    // This is the same behavior we expect from the non-root menus.
    // Implementing this with the systemp panel is slightly annoying though,
    // and required adding the MenuApplet::getSystemInfoPanelHeight method
    int16_t siT;
    if (cursor < slotCount - slotsObscured - 1) // (Minus 1: comparing zero based index with a count)
        siT = 0;
    else
        siT = 0 - ((cursor - (slotCount - slotsObscured - 1)) * itemH);

    // If showing ROOT menu,
    // and the panel isn't yet scrolled off screen top
    if (currentPage == ROOT) {
        drawSystemInfoPanel(0, siT, width()); // Draw the panel.
        itemT = max(siT + siH, 0);            // Offset the first menu entry, so menu starts below the system info panel
    }

    // drawSystemInfoPanel() changes font state (clock/info text).
    // Restore the active menu font so ROOT page item text matches other menu pages,
    // including touch-friendly layouts.
    setFont(menuItemFont);

    // Draw menu items
    // ===================

    // Which item will be drawn to the top-most slot?
    // Initially, this is the item 0, but may increase once we begin scrolling
    uint8_t firstItem;
    if (cursor < slotCount)
        firstItem = 0;
    else
        firstItem = cursor - (slotCount - 1);

    // Which item will be drawn to the bottom-most slot?
    // This may be beyond the slot-count, to draw a partially off-screen item below the bottom-most slow
    // This may be less than the slot-count, if we are reaching the end of the menuItems
    uint8_t lastItem = min((uint8_t)firstItem + slotCount, (uint8_t)items.size() - 1);

    // -- Loop: draw each (visible) menu item --
    for (uint8_t i = firstItem; i <= lastItem; i++) {

        // Grab the menu item
        MenuItem &item = items.at(i);

        // Vertical center of this slot
        int16_t center = itemT + (itemH / 2);

        // Header (non-selectable section label)
        if (item.isHeader) {
            // Header text (flush left)
            printAt(itemL + X(padding), center, item.label, LEFT, MIDDLE);

            // Subtle underline
            int16_t underlineY = itemT + itemH - 2;
            drawLine(itemL + X(padding), underlineY, itemR - X(padding), underlineY, BLACK);
        } else {
            // Box, if currently selected
            if (cursorShown && i == cursor && (!touchFriendlyLayout || !hideTouchSelectionHighlight)) {
                const int16_t selTop = itemT + selectInsetY;
                const int16_t selH = itemH - (selectInsetY * 2);
                drawRect(itemL, selTop, itemW, selH, BLACK);
                // Touch layouts need a stronger visual cue than a thin outline.
                if (touchFriendlyLayout) {
                    const int16_t markerInset = 3;
                    const int16_t markerW = 4;
                    fillRect(itemL + markerInset, selTop + markerInset, markerW, max(1, selH - (markerInset * 2)), BLACK);
                }
            }

            // Indented normal item text
            printAt(itemL + X(padding * 2), center, item.label, LEFT, MIDDLE);
        }

        // Checkbox, if relevant
        if (item.checkState) {
            const uint16_t cbWH = menuItemFont.lineHeight(); // Checkbox: width / height
            const int16_t cbL = itemR - X(padding) - cbWH;   // Checkbox: left
            const int16_t cbT = center - (cbWH / 2);         // Checkbox : top
            // Checkbox ticked
            if (*(item.checkState)) {
                drawRect(cbL, cbT, cbWH, cbWH, BLACK);
                // First point of tick: pen down
                const int16_t t1Y = center;
                const int16_t t1X = cbL + 3;
                // Second point of tick: base
                const int16_t t2Y = center + (cbWH / 2) - 2;
                const int16_t t2X = cbL + (cbWH / 2);
                // Third point of tick: end of tail
                const int16_t t3Y = center - (cbWH / 2) - 2;
                const int16_t t3X = cbL + cbWH + 2;
                // Draw twice: faux bold
                drawLine(t1X, t1Y, t2X, t2Y, BLACK);
                drawLine(t2X, t2Y, t3X, t3Y, BLACK);
                drawLine(t1X + 1, t1Y, t2X + 1, t2Y, BLACK);
                drawLine(t2X + 1, t2Y, t3X + 1, t3Y, BLACK);
            }
            // Checkbox ticked
            else
                drawRect(cbL, cbT, cbWH, cbWH, BLACK);
        }

        // Increment the y value (top) as we go
        itemT += itemH;
    }
}

bool InkHUD::MenuApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
{
    (void)longPress;

    if (freeTextMode || !getTile()) {
        return false;
    }

    const uint16_t tileL = getTile()->getLeft();
    const uint16_t tileT = getTile()->getTop();
    const uint16_t tileR = tileL + getTile()->getWidth();
    const uint16_t tileB = tileT + getTile()->getHeight();
    if (x < tileL || x >= tileR || y < tileT || y >= tileB) {
        return false;
    }

    if (items.empty()) {
        return true;
    }

    // Direct touch controls should act as activity and keep the menu open.
    OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);

    // If button-driven selection is active on touch-first layouts, clear it as soon as
    // touch interaction resumes so touch behavior remains direct/tap-first.
    if (useTouchFriendlyMenuLayout(inkhud)) {
        cursorShown = false;
        hideTouchSelectionHighlight = true;
    }

    const int16_t localY = (int16_t)y - (int16_t)tileT;

    // Keep geometry in sync with onRender() so touch hit-testing matches what users see.
    const uint16_t itemH = getMenuItemHeightPx(inkhud);
    int16_t itemT = 0;
    uint8_t slotCount = (height() - itemT) / itemH;
    if (slotCount == 0) {
        slotCount = 1;
    }
    const uint16_t &siH = systemInfoPanelHeight;
    const uint8_t slotsObscured = ceilf(siH / (float)itemH);

    if (currentPage == ROOT) {
        int16_t siT = 0;
        const int16_t scrollThreshold = (int16_t)slotCount - (int16_t)slotsObscured - 1;
        if (scrollThreshold >= 0 && (int16_t)cursor >= scrollThreshold) {
            siT = 0 - ((cursor - scrollThreshold) * itemH);
        }
        itemT = max((int16_t)(siT + siH), (int16_t)0);
    }

    const uint8_t firstItem = (cursor < slotCount) ? 0 : (cursor - (slotCount - 1));
    uint16_t visibleEnd = (uint16_t)firstItem + (uint16_t)slotCount;
    const uint8_t maxIndex = (uint8_t)items.size() - 1;
    if (visibleEnd > maxIndex) {
        visibleEnd = maxIndex;
    }
    const uint8_t lastItem = (uint8_t)visibleEnd;

    for (uint8_t i = firstItem; i <= lastItem; i++) {
        const int16_t rowTop = itemT;
        const int16_t rowBottom = itemT + itemH;

        if (localY >= rowTop && localY < rowBottom) {
            if (items.at(i).isHeader) {
                // Consume taps on headers so they don't fall back to button semantics.
                return true;
            }

            cursor = i;
            cursorShown = true;
            execute(items.at(cursor));

            if (!wantsToRender()) {
                requestUpdate(Drivers::EInk::UpdateTypes::FAST);
            }
            return true;
        }

        itemT += itemH;
    }

    // Consume taps on menu whitespace so we don't trigger button-like fallback behavior.
    return true;
}

void InkHUD::MenuApplet::onButtonShortPress()
{
    if (!freeTextMode) {
        // Push the auto-close timer back
        OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);

        // Touch-first nodes keep user-button short-press as "advance selection" in menus.
        // Any button-driven navigation should restore visible highlight.
        hideTouchSelectionHighlight = false;
        if (!settings->joystick.enabled || useTouchFriendlyMenuLayout(inkhud)) {
            if (!cursorShown) {
                cursorShown = true;
                // Select the first item that isn't a header
                cursor = 0;
                while (cursor < items.size() && items.at(cursor).isHeader) {
                    cursor++;
                }
                if (cursor >= items.size()) {
                    cursorShown = false;
                    cursor = 0;
                }
            } else {
                do {
                    cursor = (cursor + 1) % items.size();
                } while (items.at(cursor).isHeader);
            }
            requestUpdate(Drivers::EInk::UpdateTypes::FAST);
        } else {
            if (cursorShown)
                execute(items.at(cursor));
            else
                showPage(MenuPage::EXIT);
            if (!wantsToRender())
                requestUpdate(Drivers::EInk::UpdateTypes::FAST);
        }
    }
}

void InkHUD::MenuApplet::onButtonLongPress()
{
    if (!freeTextMode) {
        // Push the auto-close timer back
        OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);

        if (cursorShown)
            execute(items.at(cursor));
        else
            showPage(MenuPage::EXIT); // Special case: Peek at root-menu; longpress again to close

        // If we didn't already request a specialized update, when handling a menu action,
        // then perform the usual fast update.
        // FAST keeps things responsive: important because we're dealing with user input
        if (!wantsToRender())
            requestUpdate(Drivers::EInk::UpdateTypes::FAST);
    }
}

void InkHUD::MenuApplet::onExitShort()
{
    // Exit the menu
    showPage(MenuPage::EXIT);

    requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}

void InkHUD::MenuApplet::onNavUp()
{
    if (!freeTextMode) {
        OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);

        // Touch-first menus: swipe up/down should scroll only.
        // Keep cursor movement for scroll math, but selection box is hidden in onRender().
        if (useTouchFriendlyMenuLayout(inkhud)) {
            hideTouchSelectionHighlight = true;
            if (!cursorShown) {
                cursorShown = true;
                cursor = items.size() - 1;
                while (items.at(cursor).isHeader) {
                    if (cursor == 0) {
                        cursorShown = false;
                        break;
                    }
                    cursor--;
                }
            } else {
                do {
                    if (cursor == 0)
                        cursor = items.size() - 1;
                    else
                        cursor--;
                } while (items.at(cursor).isHeader);
            }
            requestUpdate(Drivers::EInk::UpdateTypes::FAST);
            return;
        }

        if (!cursorShown) {
            cursorShown = true;
            // Select the last item that isn't a header
            cursor = items.size() - 1;
            while (items.at(cursor).isHeader) {
                if (cursor == 0) {
                    cursorShown = false;
                    break;
                }
                cursor--;
            }
        } else {
            do {
                if (cursor == 0)
                    cursor = items.size() - 1;
                else
                    cursor--;
            } while (items.at(cursor).isHeader);
        }

        requestUpdate(Drivers::EInk::UpdateTypes::FAST);
    }
}

void InkHUD::MenuApplet::onNavDown()
{
    if (!freeTextMode) {
        OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);

        // Touch-first menus: swipe up/down should scroll only.
        // Keep cursor movement for scroll math, but selection box is hidden in onRender().
        if (useTouchFriendlyMenuLayout(inkhud)) {
            hideTouchSelectionHighlight = true;
            if (!cursorShown) {
                cursorShown = true;
                cursor = 0;
                while (cursor < items.size() && items.at(cursor).isHeader) {
                    cursor++;
                }
                if (cursor >= items.size()) {
                    cursorShown = false;
                    cursor = 0;
                }
            } else {
                do {
                    cursor = (cursor + 1) % items.size();
                } while (items.at(cursor).isHeader);
            }
            requestUpdate(Drivers::EInk::UpdateTypes::FAST);
            return;
        }

        if (!cursorShown) {
            cursorShown = true;
            // Select the first item that isn't a header
            cursor = 0;
            while (cursor < items.size() && items.at(cursor).isHeader) {
                cursor++;
            }
            if (cursor >= items.size()) {
                cursorShown = false;
                cursor = 0;
            }
        } else {
            do {
                cursor = (cursor + 1) % items.size();
            } while (items.at(cursor).isHeader);
        }

        requestUpdate(Drivers::EInk::UpdateTypes::FAST);
    }
}

void InkHUD::MenuApplet::onNavLeft()
{
    if (!freeTextMode) {
        OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);

        // Go to the previous menu page
        showPage(previousPage);
        requestUpdate(Drivers::EInk::UpdateTypes::FAST);
    }
}

void InkHUD::MenuApplet::onNavRight()
{
    if (!freeTextMode) {
        OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
        if (cursorShown)
            execute(items.at(cursor));
        if (!wantsToRender())
            requestUpdate(Drivers::EInk::UpdateTypes::FAST);
    }
}

void InkHUD::MenuApplet::onFreeText(char c)
{
    if (cm.freeTextItem.rawText.length() >= menuTextLimit && c != '\b')
        return;
    if (c == '\b') {
        if (!cm.freeTextItem.rawText.empty())
            cm.freeTextItem.rawText.pop_back();
    } else {
        cm.freeTextItem.rawText += c;
    }
    requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}

void InkHUD::MenuApplet::onFreeTextDone()
{
    // Restart the auto-close timeout
    OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
    OSThread::enabled = true;

    handleFreeText = false;
    freeTextMode = false;

    if (!cm.freeTextItem.rawText.empty()) {
        cm.selectedMessageItem = &cm.freeTextItem;
        showPage(MenuPage::CANNEDMESSAGE_RECIPIENT);
    }
    requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}

void InkHUD::MenuApplet::onFreeTextCancel()
{
    // Restart the auto-close timeout
    OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
    OSThread::enabled = true;

    handleFreeText = false;
    freeTextMode = false;

    // Clear the free text message
    cm.freeTextItem.rawText.erase();

    requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}

// Dynamically create MenuItem entries for activating / deactivating Applets, for the "Applet Selection" submenu
void InkHUD::MenuApplet::populateAppletPage()
{
    assert(items.size() == 0);

    for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
        const char *name = inkhud->userApplets.at(i)->name;
        bool *isActive = &(settings->userApplets.active[i]);
        items.push_back(MenuItem(name, MenuAction::TOGGLE_APPLET, MenuPage::APPLETS, isActive));
    }
}

// Dynamically create MenuItem entries for selecting which applets will automatically come to foreground when they have new data
// We only populate this menu page with applets which are actually active
// We use the MenuItem::checkState pointer to toggle the setting in MenuApplet::execute. Bit of a hack, but convenient.
void InkHUD::MenuApplet::populateAutoshowPage()
{
    assert(items.size() == 0);

    for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
        // Only add a menu item if applet is active
        if (settings->userApplets.active[i]) {
            const char *name = inkhud->userApplets.at(i)->name;
            bool *isActive = &(settings->userApplets.autoshow[i]);
            items.push_back(MenuItem(name, MenuAction::TOGGLE_AUTOSHOW_APPLET, MenuPage::AUTOSHOW, isActive));
        }
    }
}

// Create MenuItem entries to select our definition of "Recent"
// Controls how long data will remain in any "Recents" flavored applets
void InkHUD::MenuApplet::populateRecentsPage()
{
    // How many values are shown for use to choose from
    constexpr uint8_t optionCount = sizeof(RECENTS_OPTIONS_MINUTES) / sizeof(RECENTS_OPTIONS_MINUTES[0]);

    // Create an entry for each item in RECENTS_OPTIONS_MINUTES array
    // (Defined at top of this file)
    for (uint8_t i = 0; i < optionCount; i++) {
        std::string label = to_string(RECENTS_OPTIONS_MINUTES[i]) + " mins";
        recentsSelected[i] = (settings->recentlyActiveSeconds == RECENTS_OPTIONS_MINUTES[i] * 60);
        items.push_back(MenuItem(label.c_str(), MenuAction::SET_RECENTS, MenuPage::OPTIONS, &recentsSelected[i]));
    }
}

void InkHUD::MenuApplet::populateDisplayTimeoutPage()
{
    constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
    for (uint8_t i = 0; i < optionCount; i++) {
        displayTimeoutSelected[i] = (config.display.screen_on_secs == DISPLAY_TIMEOUT_OPTIONS[i].seconds);
        nodeConfigLabels.emplace_back(DISPLAY_TIMEOUT_OPTIONS[i].label);
        items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_DISPLAY_TIMEOUT, MenuPage::NODE_CONFIG_DISPLAY,
                                 &displayTimeoutSelected[i]));
    }
}

// MenuItem entries for the "send" page
// Dynamically creates menu items based on available canned messages
void InkHUD::MenuApplet::populateSendPage()
{
    // Position / NodeInfo packet
    items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));

    // Show the Free Text option on any node that supports the on-screen keyboard.
    if (supportsFreeTextKeyboard(inkhud, settings))
        items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND));

    // One menu item for each canned message
    uint8_t count = cm.store->size();
    for (uint8_t i = 0; i < count; i++) {
        // Gather the information for this item
        CannedMessages::MessageItem messageItem;
        messageItem.rawText = cm.store->at(i);
        messageItem.label = parse(messageItem.rawText);

        // Store the item (until the menu closes)
        cm.messageItems.push_back(messageItem);

        // Create a menu item
        const char *itemText = cm.messageItems.back().label.c_str();
        items.push_back(MenuItem(itemText, MenuAction::STORE_CANNEDMESSAGE_SELECTION, MenuPage::CANNEDMESSAGE_RECIPIENT));
    }

    items.push_back(MenuItem("Exit", MenuPage::EXIT));
}

// Dynamically create MenuItem entries for possible canned message destinations
// All available channels are shown
// Favorite nodes are shown, provided we don't have an *excessive* amount
void InkHUD::MenuApplet::populateRecipientPage()
{
    // Create recipient data (and menu items) for any channels
    // --------------------------------------------------------

    for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
        // Get the channel, and check if it's enabled
        const meshtastic_Channel &channel = channels.getByIndex(i);
        if (!channel.has_settings || channel.role == meshtastic_Channel_Role_DISABLED)
            continue;

        CannedMessages::RecipientItem r;

        // Set index
        r.channelIndex = channel.index;

        // Set a label for the menu item
        r.label = "Ch " + to_string(i) + ": ";
        if (channel.role == meshtastic_Channel_Role_PRIMARY)
            r.label += "Primary";
        else
            r.label += parse(channel.settings.name);

        // Add to the list of recipients
        cm.recipientItems.push_back(r);

        // Add a menu item for this recipient
        const char *itemText = cm.recipientItems.back().label.c_str();
        items.push_back(MenuItem(itemText, SEND_CANNEDMESSAGE, MenuPage::EXIT));
    }

    // Create recipient data (and menu items) for favorite nodes
    // ---------------------------------------------------------

    uint32_t nodeCount = nodeDB->getNumMeshNodes();
    uint32_t favoriteCount = 0;

    // Count favorites
    for (uint32_t i = 0; i < nodeCount; i++) {
        if (nodeInfoLiteIsFavorite(nodeDB->getMeshNodeByIndex(i)))
            favoriteCount++;
    }

    // Only add favorites if the number is reasonable
    // Don't want some monstrous list that takes 100 clicks to reach exit
    if (favoriteCount < 20) {
        for (uint32_t i = 0; i < nodeCount; i++) {
            const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);

            // Skip node if not a favorite
            if (!nodeInfoLiteIsFavorite(node))
                continue;

            CannedMessages::RecipientItem r;

            r.dest = node->num;
            r.channelIndex = nodeDB->getMeshNodeChannel(node->num); // Channel index only relevant if encrypted DM not possible(?)

            // Set a label for the menu item
            r.label = "DM: ";
            if (nodeInfoLiteHasUser(node))
                r.label += parse(node->long_name);
            else
                r.label += hexifyNodeNum(node->num); // Unsure if it's possible to favorite a node without NodeInfo?

            // Add to the list of recipients
            cm.recipientItems.push_back(r);

            // Add a menu item for this recipient
            const char *itemText = cm.recipientItems.back().label.c_str();
            items.push_back(MenuItem(itemText, SEND_CANNEDMESSAGE, MenuPage::EXIT));
        }
    }

    items.push_back(MenuItem("Exit", MenuPage::EXIT));
}

void InkHUD::MenuApplet::drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text)
{
    setFont(fontSmall);
    uint16_t wrapMaxH = 0;

    // Draw the text, input box, and cursor
    // Adjusting the box for screen height
    while (wrapMaxH < height - fontSmall.lineHeight()) {
        wrapMaxH += fontSmall.lineHeight();
    }

    // If the text is so long that it goes outside of the input box, the text is actually rendered off screen.
    uint32_t textHeight = getWrappedTextHeight(0, width - 5, text);
    if (!text.empty()) {
        uint16_t textPadding = X(1.0) > Y(1.0) ? wrapMaxH - textHeight : wrapMaxH - textHeight + 1;
        if (textHeight > wrapMaxH)
            printWrapped(2, textPadding, width - 5, text);
        else
            printWrapped(2, top + 2, width - 5, text);
    }

    uint16_t textCursorX = text.empty() ? 1 : getCursorX();
    uint16_t textCursorY = text.empty() ? fontSmall.lineHeight() + 2 : getCursorY() - fontSmall.lineHeight() + 3;

    if (textCursorX + 1 > width - 5) {
        textCursorX = getCursorX() - width + 5;
        textCursorY += fontSmall.lineHeight();
    }

    fillRect(textCursorX + 1, textCursorY, 1, fontSmall.lineHeight(), BLACK);

    // A white rectangle clears the top part of the screen for any text that's printed beyond the input box
    fillRect(0, 0, X(1.0), top, WHITE);

    // Draw character limit
    std::string ftlen = std::to_string(text.length()) + "/" + to_string(menuTextLimit);
    uint16_t textLen = getTextWidth(ftlen);
    printAt(X(1.0) - textLen - 2, 0, ftlen);

    // Draw the border
    drawRect(0, top, width, wrapMaxH + 5, BLACK);
}
// Renders the panel shown at the top of the root menu.
// Displays the clock, and several other pieces of instantaneous system info,
// which we'd prefer not to have displayed in a normal applet, as they update too frequently.
void InkHUD::MenuApplet::drawSystemInfoPanel(int16_t left, int16_t top, uint16_t width, uint16_t *renderedHeight)
{
    // Reset the height
    // We'll add to this as we add elements
    uint16_t height = 0;

    // Clock (potentially)
    // ====================
    std::string clockString = getTimeString();
    if (clockString.length() > 0) {
        setFont(fontMedium);
        printAt(width / 2, top, clockString, CENTER, TOP);

        height += fontMedium.lineHeight();
        height += fontMedium.lineHeight() * 0.1; // Padding below clock
    }

    // Stats
    // ===================

    setFont(fontSmall);

    // Position of the label row for the system info
    const int16_t labelT = top + height;
    height += fontSmall.lineHeight() * 1.1; // Slightly increased spacing

    // Position of the data row for the system info
    const int16_t valT = top + height;
    height += fontSmall.lineHeight() * 1.1; // Slightly increased spacing (between bottom line and divider)

    // Position of divider between the info panel and the menu entries
    const int16_t divY = top + height;
    height += fontSmall.lineHeight() * 0.2; // Padding *below* the divider. (Above first menu item)

    // Create a variable number of columns
    // Either 3 or 4, depending on whether we have GPS
    // Todo
    constexpr uint8_t N_COL = 3;
    int16_t colL[N_COL];
    int16_t colC[N_COL];
    int16_t colR[N_COL];
    for (uint8_t i = 0; i < N_COL; i++) {
        colL[i] = left + ((width / N_COL) * i);
        colC[i] = colL[i] + ((width / N_COL) / 2);
        colR[i] = colL[i] + (width / N_COL);
    }

    // Info blocks, left to right

    // Voltage
    float voltage = powerStatus->getBatteryVoltageMv() / 1000.0;
    char voltageStr[6]; // "XX.XV"
    sprintf(voltageStr, "%.2fV", voltage);
    printAt(colC[0], labelT, "Bat", CENTER, TOP);
    printAt(colC[0], valT, voltageStr, CENTER, TOP);

    // Divider
    for (int16_t y = valT; y <= divY; y += 3)
        drawPixel(colR[0], y, BLACK);

    // Channel Util
    char chUtilStr[4]; // "XX%"
    sprintf(chUtilStr, "%2.f%%", airTime->channelUtilizationPercent());
    printAt(colC[1], labelT, "Ch", CENTER, TOP);
    printAt(colC[1], valT, chUtilStr, CENTER, TOP);

    // Divider
    for (int16_t y = valT; y <= divY; y += 3)
        drawPixel(colR[1], y, BLACK);

    // Duty Cycle (AirTimeTx)
    char dutyUtilStr[4]; // "XX%"
    sprintf(dutyUtilStr, "%2.f%%", airTime->utilizationTXPercent());
    printAt(colC[2], labelT, "Duty", CENTER, TOP);
    printAt(colC[2], valT, dutyUtilStr, CENTER, TOP);

    /*
    // Divider
    for (int16_t y = valT; y <= divY; y += 3)
        drawPixel(colR[2], y, BLACK);

    // GPS satellites - todo
    printAt(colC[3], labelT, "Sats", CENTER, TOP);
    printAt(colC[3], valT, "ToDo", CENTER, TOP);
    */

    // Horizontal divider, at bottom of system info panel
    for (int16_t x = 0; x < width; x += 2) // Divider, centered in the padding between first system panel and first item
        drawPixel(x, divY, BLACK);

    if (renderedHeight != nullptr)
        *renderedHeight = height;
}

// Get the height of the the panel drawn at the top of the menu
// This is inefficient, as we do actually have to render the panel to determine the height
// It solves a catch-22 situation, where slotCount needs to know panel height, and panel height needs to know slotCount
uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
{
    // Render *far* off screen
    uint16_t height = 0;
    drawSystemInfoPanel(INT16_MIN, INT16_MIN, 1, &height);

    return height;
}

// Send a text message to the mesh
// Used to send our canned messages
void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message)
{
    meshtastic_MeshPacket *p = router->allocForSending();
    p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
    p->to = dest;
    p->channel = channel;
    p->want_ack = true;
    p->decoded.payload.size = strlen(message);
    memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);

    // Tack on a bell character if requested
    if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
        p->decoded.payload.bytes[p->decoded.payload.size] = 7;        // Bell character
        p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator
        p->decoded.payload.size++;
    }

    LOG_INFO("Send message id=%d, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes);

    service->sendToMesh(p, RX_SRC_LOCAL, true); // Send to mesh, cc to phone
}

// Free up any heap memory we'd used while selecting / sending canned messages
void InkHUD::MenuApplet::freeCannedMessageResources()
{
    cm.selectedMessageItem = nullptr;
    cm.selectedRecipientItem = nullptr;
    cm.messageItems.clear();
    cm.recipientItems.clear();
}
#endif // MESHTASTIC_INCLUDE_INKHUD
