From dffbaebd4d9624403fd31766e179e3fc04b98c68 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:05:12 +0100 Subject: [PATCH 01/12] Update statusbar immediately upon creation Prevent delay at first show --- .../Source/service/statusbar/Statusbar.cpp | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/Tactility/Source/service/statusbar/Statusbar.cpp b/Tactility/Source/service/statusbar/Statusbar.cpp index feee0228d..5591ed308 100644 --- a/Tactility/Source/service/statusbar/Statusbar.cpp +++ b/Tactility/Source/service/statusbar/Statusbar.cpp @@ -324,18 +324,22 @@ class StatusbarService final : public Service { } } + void updateIcons() { + if (lvgl_try_lock(200)) { + updateGpsIcon(); + updateMeshIcon(); + updateBluetoothIcon(); + updateWifiIcon(); + updatePowerStatusIcon(); + updateUsbIcon(); + updateInstallIcon(); + lvgl_unlock(); + } + } + void update() { if (lvgl_is_running()) { - if (lvgl_try_lock(200)) { - updateGpsIcon(); - updateMeshIcon(); - updateBluetoothIcon(); - updateWifiIcon(); - updatePowerStatusIcon(); - updateUsbIcon(); - updateInstallIcon(); - lvgl_unlock(); - } + updateIcons(); } } @@ -383,7 +387,8 @@ class StatusbarService final : public Service { auto service = findServiceById(manifest.id); assert(service); - service->update(); + // LVGL only counts as running after its start callback returned, which is what starts this service + service->updateIcons(); updateTimer = std::make_unique(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [service] { service->update(); From fd6674dacd846e18169cba9bc08d5ba01ecf22f0 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:05:20 +0100 Subject: [PATCH 02/12] Merge mesh apps into 1 --- ...ty.meshchannels.svg => tactility.mesh.svg} | 0 ...nnels_mono.svg => tactility.mesh_mono.svg} | 0 Tactility/Source/InitApps.cpp | 6 +- Tactility/Source/app/launcher/AppsMode.cpp | 3 +- .../{MeshChannels.cpp => MeshApp.cpp} | 274 +++++++++++-- .../Source/app/meshchat/MeshContacts.cpp | 360 ------------------ 6 files changed, 247 insertions(+), 396 deletions(-) rename Data/system/app_icon/{tactility.meshchannels.svg => tactility.mesh.svg} (100%) rename Data/system/app_icon/{tactility.meshchannels_mono.svg => tactility.mesh_mono.svg} (100%) rename Tactility/Source/app/meshchat/{MeshChannels.cpp => MeshApp.cpp} (60%) delete mode 100644 Tactility/Source/app/meshchat/MeshContacts.cpp diff --git a/Data/system/app_icon/tactility.meshchannels.svg b/Data/system/app_icon/tactility.mesh.svg similarity index 100% rename from Data/system/app_icon/tactility.meshchannels.svg rename to Data/system/app_icon/tactility.mesh.svg diff --git a/Data/system/app_icon/tactility.meshchannels_mono.svg b/Data/system/app_icon/tactility.mesh_mono.svg similarity index 100% rename from Data/system/app_icon/tactility.meshchannels_mono.svg rename to Data/system/app_icon/tactility.mesh_mono.svg diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp index 816623c04..d48e98b76 100644 --- a/Tactility/Source/InitApps.cpp +++ b/Tactility/Source/InitApps.cpp @@ -71,8 +71,7 @@ namespace app { namespace terminal { extern const ::AppManifest manifest; } namespace timedatesettings { extern const ::AppManifest manifest; } namespace meshchat { - extern const ::AppManifest contactsManifest; - extern const ::AppManifest channelsManifest; + extern const ::AppManifest manifest; extern const ::AppManifest conversationManifest; extern const ::AppManifest addContactManifest; } @@ -169,8 +168,7 @@ static void registerInternalApps() { } if (settings::radio::hasRadio()) { - app_manager_add(&app::meshchat::contactsManifest); - app_manager_add(&app::meshchat::channelsManifest); + app_manager_add(&app::meshchat::manifest); app_manager_add(&app::meshchat::conversationManifest); app_manager_add(&app::meshchat::addContactManifest); app_manager_add(&app::meshsettings::manifest); diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index bf659702f..0974710d9 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -64,8 +64,7 @@ const BuiltInIconAsset BUILT_IN_ICONS[] = { BUILT_IN_ICON("tactility.files", tactility_files), BUILT_IN_ICON("tactility.i2cscanner", tactility_i2cscanner), BUILT_IN_ICON("tactility.maps", tactility_maps), - BUILT_IN_ICON("tactility.meshchannels", tactility_meshchannels), - BUILT_IN_ICON("tactility.meshcontacts", tactility_meshcontacts), + BUILT_IN_ICON("tactility.mesh", tactility_mesh), BUILT_IN_ICON("tactility.notes", tactility_notes), BUILT_IN_ICON("tactility.systeminfo", tactility_systeminfo), BUILT_IN_ICON("tactility.terminal", tactility_terminal), diff --git a/Tactility/Source/app/meshchat/MeshChannels.cpp b/Tactility/Source/app/meshchat/MeshApp.cpp similarity index 60% rename from Tactility/Source/app/meshchat/MeshChannels.cpp rename to Tactility/Source/app/meshchat/MeshApp.cpp index 5ad7f5dc4..b144b5aa1 100644 --- a/Tactility/Source/app/meshchat/MeshChannels.cpp +++ b/Tactility/Source/app/meshchat/MeshApp.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -20,6 +21,8 @@ #include #include +#include +#include #include #include #include @@ -31,32 +34,40 @@ namespace tt::app::meshchat { -extern const ::AppManifest channelsManifest; +extern const ::AppManifest manifest; namespace { -constexpr auto* TAG = "MeshChannels"; +constexpr auto* TAG = "Mesh"; constexpr size_t CHANNEL_LIMIT = 8; +constexpr size_t CONTACT_LIMIT = 64; + +enum class ViewMode { Menu, Contacts, Channels }; struct Context { uint32_t appInstanceId; TaskEventGroup* eventGroup = nullptr; uint32_t openBit = 0; + uint32_t menuBit = 0; uint32_t settingsBit = 0; uint32_t addChannelBit = 0; + uint32_t showChannelsBit = 0; + uint32_t showContactsBit = 0; + ViewMode mode = ViewMode::Menu; /** Written by the LVGL thread before it signals openBit */ ::std::atomic openIndex = 0; + uint32_t menuDialogId = 0; uint32_t addTypeDialogId = 0; /** The kind of channel for each option of the open selection dialog */ ::std::vector addTypes; /** Whether the mesh network supports any way to add a channel. Read by the app's task. */ bool canAddChannel = false; - // Valid while the window is shown - lv_obj_t* addButton = nullptr; uint32_t joinChannelDialogId = 0; uint32_t createChannelDialogId = 0; uint32_t privateNameDialogId = 0; uint32_t privateKeyDialogId = 0; + uint32_t contactNameDialogId = 0; + uint32_t contactKeyDialogId = 0; uint32_t messageDialogId = 0; AppStream channelStream {}; char channelBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; @@ -68,10 +79,19 @@ struct Context { char privateKeyBuffer[MESH_CHANNEL_KEY_MAX_LENGTH * 2 + 1] = {}; /** The name that was entered while the key is asked */ ::std::string pendingChannelName; + AppStream contactNameStream {}; + char contactNameBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream contactKeyStream {}; + char contactKeyBuffer[MESH_ADDRESS_MAX_LENGTH * 2 + 1] = {}; + /** The name that was entered while the key is asked */ + ::std::string pendingContactName; tt::std::vector channels; + tt::std::vector contacts; // Valid while the window is shown + lv_obj_t* root = nullptr; lv_obj_t* networkHeader = nullptr; lv_obj_t* content = nullptr; + lv_obj_t* addButton = nullptr; NetworkInfo network; }; @@ -85,6 +105,12 @@ void loadData(Context* ctx) { count = 0; } ctx->channels.resize(count); + ctx->contacts.assign(CONTACT_LIMIT, MeshContact {}); + count = 0; + if (mesh_get_contacts(ctx->contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { + count = 0; + } + ctx->contacts.resize(count); } lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback, void* callbackData, intptr_t index) { @@ -94,21 +120,22 @@ lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback return button; } -void onChannelPressed(lv_event_t* event) { +void onItemPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); ctx->openIndex = (int32_t)reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))); task_event_group_signal(ctx->eventGroup, ctx->openBit); } -void onAddChannelPressed(lv_event_t* event) { +void signalBit(lv_event_t* event, uint32_t Context::* bit) { auto* ctx = static_cast(lv_event_get_user_data(event)); - task_event_group_signal(ctx->eventGroup, ctx->addChannelBit); + task_event_group_signal(ctx->eventGroup, ctx->*bit); } -void onSettingsPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - task_event_group_signal(ctx->eventGroup, ctx->settingsBit); -} +void onAddChannelPressed(lv_event_t* event) { signalBit(event, &Context::addChannelBit); } +void onMenuPressed(lv_event_t* event) { signalBit(event, &Context::menuBit); } +void onSettingsPressed(lv_event_t* event) { signalBit(event, &Context::settingsBit); } +void onShowChannelsPressed(lv_event_t* event) { signalBit(event, &Context::showChannelsBit); } +void onShowContactsPressed(lv_event_t* event) { signalBit(event, &Context::showContactsBit); } void onBackPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); @@ -120,30 +147,59 @@ void updateWidgets(Context* ctx) { if (ctx->content == nullptr) { return; } + if (ctx->mode == ViewMode::Menu) { + return; + } updateNetworkHeader(ctx->networkHeader, ctx->network); - lv_obj_set_hidden(ctx->addButton, !ctx->canAddChannel); lv_obj_clean(ctx->content); - auto* channelList = createCard(ctx->content); - for (size_t i = 0; i < ctx->channels.size(); i++) { - addListButton(channelList, ctx->channels[i].name, onChannelPressed, ctx, (intptr_t)i); + auto* list = createCard(ctx->content); + if (ctx->mode == ViewMode::Channels) { + lv_obj_set_hidden(ctx->addButton, !ctx->canAddChannel); + for (size_t i = 0; i < ctx->channels.size(); i++) { + addListButton(list, ctx->channels[i].name, onItemPressed, ctx, (intptr_t)i); + } + } else { + for (size_t i = 0; i < ctx->contacts.size(); i++) { + addListButton(list, ctx->contacts[i].name, onItemPressed, ctx, (intptr_t)i); + } + if (ctx->contacts.empty()) { + lvgl_list_add_text(list, "No contacts yet. Add a node that you've heard, or wait for a direct message."); + } } - lvgl_list_hide_last_divider(channelList); + lvgl_list_hide_last_divider(list); } void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); + ctx->root = parent; lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* toolbar = lvgl_toolbar_create(parent, "Mesh Channels"); - lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - ctx->addButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx); - lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx); + const char* title = ctx->mode == ViewMode::Channels ? "Channels" : ctx->mode == ViewMode::Contacts ? "Contacts" : "Mesh"; + auto* toolbar = lvgl_toolbar_create(parent, title); + lvgl_toolbar_set_nav_action(toolbar, ctx->mode == ViewMode::Menu ? LV_SYMBOL_CLOSE : LV_SYMBOL_LEFT, onBackPressed, ctx); + + if (ctx->mode == ViewMode::Menu) { + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx); + auto* menuContent = createContent(parent); + ctx->content = menuContent; + auto* list = createCard(menuContent); + addListButton(list, "Channels", onShowChannelsPressed, ctx, 0); + addListButton(list, "Contacts", onShowContactsPressed, ctx, 0); + lvgl_list_hide_last_divider(list); + return; + } + + if (ctx->mode == ViewMode::Channels) { + ctx->addButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx); + } else { + auto* menuButton = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_MORE_VERT, onMenuPressed, ctx); + lv_obj_set_style_text_font(menuButton, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + } ctx->networkHeader = createNetworkHeader(parent); - ctx->content = createContent(parent); updateWidgets(ctx); @@ -151,17 +207,42 @@ void createWidgets(lv_obj_t* parent, void* userData) { void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); + ctx->root = nullptr; ctx->networkHeader = nullptr; ctx->content = nullptr; ctx->addButton = nullptr; } +/** Requires the LVGL lock */ +void rebuildWidgets(Context* ctx) { + if (ctx->root == nullptr) { + return; + } + auto* root = ctx->root; + lv_obj_clean(root); + ctx->networkHeader = nullptr; + ctx->content = nullptr; + ctx->addButton = nullptr; + createWidgets(root, ctx); +} + +void setMode(Context* ctx, ViewMode mode) { + lvgl_lock(); + ctx->mode = mode; + rebuildWidgets(ctx); + lvgl_unlock(); +} + void startConversation(Context* ctx) { const auto index = (size_t)ctx->openIndex.load(); - if (index < ctx->channels.size()) { + if (ctx->mode == ViewMode::Channels && index < ctx->channels.size()) { const ::std::string channelIndex = ::std::to_string(ctx->channels[index].index); const char* argv[] = { KIND_CHANNEL, channelIndex.c_str(), ctx->channels[index].name }; startApp(CONVERSATION_APP_ID, 3, argv); + } else if (ctx->mode == ViewMode::Contacts && index < ctx->contacts.size()) { + const ::std::string address = addressToHex(ctx->contacts[index].address); + const char* argv[] = { KIND_CONTACT, address.c_str(), ctx->contacts[index].name }; + startApp(CONVERSATION_APP_ID, 3, argv); } } @@ -343,6 +424,114 @@ void onPrivateKeyResult(Context* ctx, int32_t result) { ctx->pendingChannelName.clear(); } +void startAddContact() { + startApp(ADD_CONTACT_APP_ID, 0, nullptr); +} + +/** Shows this node as a QR code that other mesh apps can scan */ +void showOwnQr(Context* ctx) { + char uri[256]; + const error_t error = mesh_get_contact_uri(nullptr, uri, sizeof(uri)); + if (error == ERROR_NONE) { + qrcode::start(uri); + } else if (error == ERROR_NOT_SUPPORTED) { + showMessage(ctx, "Not available", "This mesh network can't share a contact as a QR code."); + } else { + LOG_E(TAG, "Failed to get the contact: %s", error_to_string(error)); + showMessage(ctx, "Couldn't create QR code", error == ERROR_INVALID_STATE ? getNotReadyText().c_str() : error_to_string(error)); + } +} + +void startContactNameDialog(Context* ctx) { + if (ctx->contactNameDialogId != 0 || ctx->contactKeyDialogId != 0) { + return; + } + ctx->contactNameDialogId = inputdialog::start( + ctx->appInstanceId, + "Add contact", + "Name of the contact", + "", + ctx->contactNameStream, + ctx->contactNameBuffer, + sizeof(ctx->contactNameBuffer), + ctx->eventGroup + ); +} + +void onContactNameResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::contactNameBuffer)] = {}; + const size_t length = app_stream_read(&ctx->contactNameStream, name, sizeof(name) - 1); + name[length] = '\0'; + ctx->pendingContactName = name; + } + app_stream_unsubscribe(&ctx->contactNameStream); + if (result != 0 || ctx->pendingContactName.empty()) { + ctx->pendingContactName.clear(); + return; + } + ctx->contactKeyDialogId = inputdialog::start( + ctx->appInstanceId, + "Public key", + "Public key of the contact in hexadecimal", + "", + ctx->contactKeyStream, + ctx->contactKeyBuffer, + sizeof(ctx->contactKeyBuffer), + ctx->eventGroup + ); +} + +void onContactKeyResult(Context* ctx, int32_t result) { + if (result == 0) { + char key[sizeof(Context::contactKeyBuffer)] = {}; + const size_t length = app_stream_read(&ctx->contactKeyStream, key, sizeof(key) - 1); + key[length] = '\0'; + MeshAddress address; + if (!hexToAddress(key, &address)) { + showMessage(ctx, "Invalid key", "Enter the public key as hexadecimal characters, two for every byte."); + } else { + const error_t error = mesh_add_contact(&address, ctx->pendingContactName.c_str()); + if (error == ERROR_INVALID_ARGUMENT) { + showMessage(ctx, "Invalid key", "This mesh network expects a public key of a different length."); + } else if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to add contact: %s", error_to_string(error)); + showMessage(ctx, "Couldn't add contact", error == ERROR_RESOURCE ? "There are too many contacts." : error_to_string(error)); + } + } + } + app_stream_unsubscribe(&ctx->contactKeyStream); + ctx->pendingContactName.clear(); +} + +/** The order of these matches the options of the selection dialog */ +enum class MenuItem { AddByKey, AddSeen, ShowQr }; + +void startMenu(Context* ctx) { + if (ctx->menuDialogId != 0) { + return; + } + ctx->menuDialogId = selectiondialog::start(ctx->appInstanceId, "Mesh Contacts", { + "Add by key", + "Add seen", + "Show my QR" + }); +} + +void onMenuSelected(Context* ctx, int32_t selected) { + switch (static_cast(selected)) { + case MenuItem::AddByKey: + startContactNameDialog(ctx); + break; + case MenuItem::AddSeen: + startAddContact(); + break; + case MenuItem::ShowQr: + showOwnQr(ctx); + break; + } +} + int32_t appMain(int argc, char* argv[]) { Context ctx {}; ctx.appInstanceId = app_scheduler_current_app_id(); @@ -351,8 +540,11 @@ int32_t appMain(int argc, char* argv[]) { task_event_group_construct(&eventGroup); ctx.eventGroup = &eventGroup; check(task_event_group_claim_bit(&eventGroup, &ctx.openBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.menuBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.settingsBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.addChannelBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.showChannelsBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.showContactsBit) == ERROR_NONE); AppEventSubscription appEvents {}; check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); @@ -370,27 +562,44 @@ int32_t appMain(int argc, char* argv[]) { if (flags & ctx.openBit) { startConversation(&ctx); } + if (flags & ctx.menuBit) { + startMenu(&ctx); + } if (flags & ctx.settingsBit) { startApp(MESH_SETTINGS_APP_ID, 0, nullptr); } if (flags & ctx.addChannelBit) { startAddChannel(&ctx); } + if (flags & ctx.showChannelsBit) { + setMode(&ctx, ViewMode::Channels); + } + if (flags & ctx.showContactsBit) { + setMode(&ctx, ViewMode::Contacts); + } bool changed = false; MeshEvent meshEvent {}; while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { - changed |= meshEvent.type == MESH_EVENT_STATE || meshEvent.type == MESH_EVENT_CHANNEL; + changed |= meshEvent.type == MESH_EVENT_STATE || meshEvent.type == MESH_EVENT_CHANNEL || meshEvent.type == MESH_EVENT_CONTACT; } AppEvent event {}; while (app_event_poll(&appEvents, &event) == ERROR_NONE) { if (event.type == APP_EVENT_CLOSE) { - shouldClose = true; - break; + if (ctx.mode == ViewMode::Menu) { + shouldClose = true; + break; + } + setMode(&ctx, ViewMode::Menu); } if (event.type == APP_EVENT_RESULT) { - if (event.result.launch_id == ctx.addTypeDialogId) { + if (event.result.launch_id == ctx.menuDialogId) { + ctx.menuDialogId = 0; + if (event.result.result >= 0) { + onMenuSelected(&ctx, event.result.result); + } + } else if (event.result.launch_id == ctx.addTypeDialogId) { ctx.addTypeDialogId = 0; if (event.result.result >= 0) { onAddTypeSelected(&ctx, event.result.result); @@ -407,6 +616,12 @@ int32_t appMain(int argc, char* argv[]) { } else if (event.result.launch_id == ctx.joinChannelDialogId) { ctx.joinChannelDialogId = 0; onJoinChannelResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.contactNameDialogId) { + ctx.contactNameDialogId = 0; + onContactNameResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.contactKeyDialogId) { + ctx.contactKeyDialogId = 0; + onContactKeyResult(&ctx, event.result.result); } else if (event.result.launch_id == ctx.messageDialogId) { ctx.messageDialogId = 0; } @@ -433,9 +648,9 @@ int32_t appMain(int argc, char* argv[]) { } // namespace -extern const ::AppManifest channelsManifest = { - .id = "tactility.meshchannels", - .name = "Mesh Channels", +extern const ::AppManifest manifest = { + .id = "tactility.mesh", + .name = "Mesh", .category = APP_CATEGORY_USER, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = 0, @@ -443,4 +658,3 @@ extern const ::AppManifest channelsManifest = { }; } // namespace tt::app::meshchat - diff --git a/Tactility/Source/app/meshchat/MeshContacts.cpp b/Tactility/Source/app/meshchat/MeshContacts.cpp deleted file mode 100644 index cbe1efccf..000000000 --- a/Tactility/Source/app/meshchat/MeshContacts.cpp +++ /dev/null @@ -1,360 +0,0 @@ -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include - -#include - -#include - -#include -#include -#include - -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace tt::app::meshchat { - -extern const ::AppManifest contactsManifest; - -namespace { - -constexpr auto* TAG = "MeshContacts"; -constexpr size_t CONTACT_LIMIT = 64; - -struct Context { - uint32_t appInstanceId; - TaskEventGroup* eventGroup = nullptr; - uint32_t openBit = 0; - uint32_t menuBit = 0; - /** Written by the LVGL thread before it signals openBit */ - ::std::atomic openIndex = 0; - uint32_t menuDialogId = 0; - uint32_t contactNameDialogId = 0; - uint32_t contactKeyDialogId = 0; - uint32_t messageDialogId = 0; - AppStream contactNameStream {}; - char contactNameBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; - AppStream contactKeyStream {}; - char contactKeyBuffer[MESH_ADDRESS_MAX_LENGTH * 2 + 1] = {}; - /** The name that was entered while the key is asked */ - ::std::string pendingContactName; - tt::std::vector contacts; - // Valid while the window is shown - lv_obj_t* networkHeader = nullptr; - lv_obj_t* content = nullptr; - NetworkInfo network; -}; - -/** Queries the service thread, so it's called without the LVGL lock */ -void loadData(Context* ctx) { - ctx->network = loadNetworkInfo(); - ctx->contacts.assign(CONTACT_LIMIT, MeshContact {}); - size_t count = 0; - if (mesh_get_contacts(ctx->contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { - count = 0; - } - ctx->contacts.resize(count); -} - -lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback, void* callbackData, intptr_t index) { - auto* button = lvgl_list_add_button(list, nullptr, text); - lv_obj_set_user_data(button, reinterpret_cast(index)); - lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, callbackData); - return button; -} - -void onContactPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - auto* button = lv_event_get_target_obj(event); - ctx->openIndex = (int32_t)reinterpret_cast(lv_obj_get_user_data(button)); - task_event_group_signal(ctx->eventGroup, ctx->openBit); -} - -void onMenuPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - task_event_group_signal(ctx->eventGroup, ctx->menuBit); -} - -void onBackPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - app_event_emit_close(ctx->appInstanceId); -} - -/** Requires the LVGL lock */ -void updateWidgets(Context* ctx) { - if (ctx->content == nullptr) { - return; - } - updateNetworkHeader(ctx->networkHeader, ctx->network); - - lv_obj_clean(ctx->content); - auto* contactList = createCard(ctx->content); - for (size_t i = 0; i < ctx->contacts.size(); i++) { - addListButton(contactList, ctx->contacts[i].name, onContactPressed, ctx, (intptr_t)i); - } - if (ctx->contacts.empty()) { - lvgl_list_add_text(contactList, "No contacts yet. Add a node that you've heard, or wait for a direct message."); - } - lvgl_list_hide_last_divider(contactList); -} - -void createWidgets(lv_obj_t* parent, void* userData) { - auto* ctx = static_cast(userData); - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - auto* toolbar = lvgl_toolbar_create(parent, "Mesh Contacts"); - lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - auto* menuButton = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_MORE_VERT, onMenuPressed, ctx); - lv_obj_set_style_text_font(menuButton, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); - - ctx->networkHeader = createNetworkHeader(parent); - - ctx->content = createContent(parent); - - updateWidgets(ctx); -} - -void destroyWidgets(void* userData) { - auto* ctx = static_cast(userData); - ctx->networkHeader = nullptr; - ctx->content = nullptr; -} - -void startConversation(Context* ctx) { - const auto index = (size_t)ctx->openIndex.load(); - if (index < ctx->contacts.size()) { - const ::std::string address = addressToHex(ctx->contacts[index].address); - const char* argv[] = { KIND_CONTACT, address.c_str(), ctx->contacts[index].name }; - startApp(CONVERSATION_APP_ID, 3, argv); - } -} - -void showMessage(Context* ctx, const char* title, const char* message) { - if (ctx->messageDialogId == 0) { - ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, title, message); - } -} - -void startAddContact() { - startApp(ADD_CONTACT_APP_ID, 0, nullptr); -} - -/** Shows this node as a QR code that other mesh apps can scan */ -void showOwnQr(Context* ctx) { - char uri[256]; - const error_t error = mesh_get_contact_uri(nullptr, uri, sizeof(uri)); - if (error == ERROR_NONE) { - qrcode::start(uri); - } else if (error == ERROR_NOT_SUPPORTED) { - showMessage(ctx, "Not available", "This mesh network can't share a contact as a QR code."); - } else { - LOG_E(TAG, "Failed to get the contact: %s", error_to_string(error)); - showMessage(ctx, "Couldn't create QR code", error == ERROR_INVALID_STATE ? getNotReadyText().c_str() : error_to_string(error)); - } -} - -void startContactNameDialog(Context* ctx) { - if (ctx->contactNameDialogId != 0 || ctx->contactKeyDialogId != 0) { - return; - } - ctx->contactNameDialogId = inputdialog::start( - ctx->appInstanceId, - "Add contact", - "Name of the contact", - "", - ctx->contactNameStream, - ctx->contactNameBuffer, - sizeof(ctx->contactNameBuffer), - ctx->eventGroup - ); -} - -void onContactNameResult(Context* ctx, int32_t result) { - if (result == 0) { - char name[sizeof(Context::contactNameBuffer)] = {}; - const size_t length = app_stream_read(&ctx->contactNameStream, name, sizeof(name) - 1); - name[length] = '\0'; - ctx->pendingContactName = name; - } - app_stream_unsubscribe(&ctx->contactNameStream); - if (result != 0 || ctx->pendingContactName.empty()) { - ctx->pendingContactName.clear(); - return; - } - ctx->contactKeyDialogId = inputdialog::start( - ctx->appInstanceId, - "Public key", - "Public key of the contact in hexadecimal", - "", - ctx->contactKeyStream, - ctx->contactKeyBuffer, - sizeof(ctx->contactKeyBuffer), - ctx->eventGroup - ); -} - -void onContactKeyResult(Context* ctx, int32_t result) { - if (result == 0) { - char key[sizeof(Context::contactKeyBuffer)] = {}; - const size_t length = app_stream_read(&ctx->contactKeyStream, key, sizeof(key) - 1); - key[length] = '\0'; - MeshAddress address; - if (!hexToAddress(key, &address)) { - showMessage(ctx, "Invalid key", "Enter the public key as hexadecimal characters, two for every byte."); - } else { - const error_t error = mesh_add_contact(&address, ctx->pendingContactName.c_str()); - if (error == ERROR_INVALID_ARGUMENT) { - showMessage(ctx, "Invalid key", "This mesh network expects a public key of a different length."); - } else if (error != ERROR_NONE) { - LOG_E(TAG, "Failed to add contact: %s", error_to_string(error)); - showMessage(ctx, "Couldn't add contact", error == ERROR_RESOURCE ? "There are too many contacts." : error_to_string(error)); - } - } - } - app_stream_unsubscribe(&ctx->contactKeyStream); - ctx->pendingContactName.clear(); -} - -/** The order of these matches the options of the selection dialog */ -enum class MenuItem { AddByKey, AddSeen, ShowQr, Settings }; - -void startMenu(Context* ctx) { - if (ctx->menuDialogId != 0) { - return; - } - ctx->menuDialogId = selectiondialog::start(ctx->appInstanceId, "Mesh Contacts", { - "Add by key", - "Add seen", - "Show my QR", - "Settings" - }); -} - -void onMenuSelected(Context* ctx, int32_t selected) { - switch (static_cast(selected)) { - case MenuItem::AddByKey: - startContactNameDialog(ctx); - break; - case MenuItem::AddSeen: - startAddContact(); - break; - case MenuItem::ShowQr: - showOwnQr(ctx); - break; - case MenuItem::Settings: - startApp(MESH_SETTINGS_APP_ID, 0, nullptr); - break; - } -} - -int32_t appMain(int argc, char* argv[]) { - Context ctx {}; - ctx.appInstanceId = app_scheduler_current_app_id(); - - TaskEventGroup eventGroup {}; - task_event_group_construct(&eventGroup); - ctx.eventGroup = &eventGroup; - check(task_event_group_claim_bit(&eventGroup, &ctx.openBit) == ERROR_NONE); - check(task_event_group_claim_bit(&eventGroup, &ctx.menuBit) == ERROR_NONE); - - AppEventSubscription appEvents {}; - check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); - MeshEventSubscription meshEvents {}; - check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); - - loadData(&ctx); - WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); - - bool shouldClose = false; - while (!shouldClose) { - uint32_t flags = 0; - task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); - - if (flags & ctx.openBit) { - startConversation(&ctx); - } - if (flags & ctx.menuBit) { - startMenu(&ctx); - } - - bool changed = false; - MeshEvent meshEvent {}; - while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { - changed |= meshEvent.type == MESH_EVENT_STATE || meshEvent.type == MESH_EVENT_CONTACT; - } - - AppEvent event {}; - while (app_event_poll(&appEvents, &event) == ERROR_NONE) { - if (event.type == APP_EVENT_CLOSE) { - shouldClose = true; - break; - } - if (event.type == APP_EVENT_RESULT) { - if (event.result.launch_id == ctx.menuDialogId) { - ctx.menuDialogId = 0; - if (event.result.result >= 0) { - onMenuSelected(&ctx, event.result.result); - } - } else if (event.result.launch_id == ctx.contactNameDialogId) { - ctx.contactNameDialogId = 0; - onContactNameResult(&ctx, event.result.result); - } else if (event.result.launch_id == ctx.contactKeyDialogId) { - ctx.contactKeyDialogId = 0; - onContactKeyResult(&ctx, event.result.result); - } else if (event.result.launch_id == ctx.messageDialogId) { - ctx.messageDialogId = 0; - } - app_manager_stop(event.result.launch_id); - // The channels and contacts may have changed, and the window may have been rebuilt - changed = true; - } - } - - if (changed) { - loadData(&ctx); - lvgl_lock(); - updateWidgets(&ctx); - lvgl_unlock(); - } - } - - window_manager_remove(window); - check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); - check(app_event_unsubscribe(&appEvents) == ERROR_NONE); - task_event_group_destruct(&eventGroup); - return 0; -} - -} // namespace - -extern const ::AppManifest contactsManifest = { - .id = "tactility.meshcontacts", - .name = "Mesh Contacts", - .category = APP_CATEGORY_USER, - .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, - .flags = 0, - .stack = { .depth = 6144, .desired_memory_capability = 0 }, -}; - -} // namespace tt::app::meshchat - From 09813b24676707a04f82527977dfb15406486878 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:15:23 +0100 Subject: [PATCH 03/12] Fix for simulator ui size and spacing --- Devices/simulator/Source/SimulatorProfile.cpp | 12 ++++++------ Devices/simulator/Source/SimulatorProfile.h | 6 ++++-- Devices/simulator/Source/module.cpp | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Devices/simulator/Source/SimulatorProfile.cpp b/Devices/simulator/Source/SimulatorProfile.cpp index 553ad3d83..f85f858ef 100644 --- a/Devices/simulator/Source/SimulatorProfile.cpp +++ b/Devices/simulator/Source/SimulatorProfile.cpp @@ -28,12 +28,12 @@ constexpr auto* DISPLAY_SETTINGS_PATH = "data/tactility/appdata/tactility.displa constexpr auto* ORIENTATION_KEY = "orientation="; static const Profile PROFILES[PROFILE_COUNT] = { - { "Default", 640, 320, LV_DPI_DEF, false, false, false, true, true }, - { "CL-32", 384, 168, 139, false, true, true, true, false }, - { "LilyGO T-Deck", 320, 240, 143, false, false, false, true, true }, - { "LilyGO T-Lora Pager", 480, 222, 150, false, false, false, true, false }, - { "M5Stack Cardputer", 240, 135, 139, false, true, false, true, false }, - { "M5Stack Tab5", 1280, 720, 250, true, false, false, true, true }, + { "Default", 640, 320, LV_DPI_DEF, false, 100, 100, false, true, true }, + { "CL-32", 384, 168, 139, false, 40, 0, true, true, false }, + { "LilyGO T-Deck", 320, 240, 143, false, 40, 60, false, true, true }, + { "LilyGO T-Lora Pager", 480, 222, 150, false, 40, 0, false, true, false }, + { "M5Stack Cardputer", 240, 135, 139, false, 20, 0, false, true, false }, + { "M5Stack Tab5", 1280, 720, 250, true, 100, 100, false, true, true }, }; static size_t activeProfileIndex = 0; diff --git a/Devices/simulator/Source/SimulatorProfile.h b/Devices/simulator/Source/SimulatorProfile.h index 13fdf8c5a..05d837f85 100644 --- a/Devices/simulator/Source/SimulatorProfile.h +++ b/Devices/simulator/Source/SimulatorProfile.h @@ -16,8 +16,10 @@ struct Profile { uint16_t dpi; // lvgl.dpi from device.properties (if set), otherwise display.dpi /** The default rotation: the display is taller than wide. The width and height values may be given in either order. */ bool portrait; - /** Compact UI density */ - bool compact; + /** LVGL UI spacing, from lvgl.uiSpacing of the device */ + uint8_t uiSpacing; + /** LVGL UI size, from lvgl.uiSize of the device */ + uint8_t uiSize; /** Greyscale display, which makes the monochrome theme apply */ bool mono; bool keyboard; diff --git a/Devices/simulator/Source/module.cpp b/Devices/simulator/Source/module.cpp index c07d5f8c9..5360581d5 100644 --- a/Devices/simulator/Source/module.cpp +++ b/Devices/simulator/Source/module.cpp @@ -112,8 +112,8 @@ extern "C" { static error_t start() { simulator::initActiveProfile(); - const uint8_t density = simulator::getProfile(simulator::getActiveProfileIndex()).compact ? 0 : 100; - lvgl_theme_set_default_density(density, density); + const auto& profile = simulator::getProfile(simulator::getActiveProfileIndex()); + lvgl_theme_set_default_density(profile.uiSpacing, profile.uiSize); error_t error = app_manager_add(&simulator::profileAppManifest); if (error != ERROR_NONE) { return error; From f79da06405e2ed749999b26cd5474888fe1a8136 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:18:30 +0100 Subject: [PATCH 04/12] Fix keyboard input in terminal (sticky return key) --- .../Tactility/app/terminal/KeyboardInput.h | 3 ++ .../Source/app/terminal/KeyboardInput.cpp | 28 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h index beda05d39..7e61ba65b 100644 --- a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h +++ b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h @@ -35,8 +35,11 @@ class KeyboardInput { void refresh(); void rescan(); void applyExclusive(); + void awaitKeyRelease(); std::vector devices_; TickType_t lastRefresh_ = 0; bool exclusive_ = false; + /** Keyboards may keep reporting a held key as pressed, so a key only counts on its press edge */ + uint32_t heldKey_ = 0; }; diff --git a/Tactility/Source/app/terminal/KeyboardInput.cpp b/Tactility/Source/app/terminal/KeyboardInput.cpp index 368f1120b..8749307d0 100644 --- a/Tactility/Source/app/terminal/KeyboardInput.cpp +++ b/Tactility/Source/app/terminal/KeyboardInput.cpp @@ -12,12 +12,32 @@ KeyboardInput::KeyboardInput() { } KeyboardInput::~KeyboardInput() { + awaitKeyRelease(); setExclusive(false); for (Device* device : devices_) { device_put(device); } } +/** A key that is still down would otherwise be seen as a fresh press by the LVGL indevs that are enabled again */ +void KeyboardInput::awaitKeyRelease() { + constexpr TickType_t timeout = pdMS_TO_TICKS(1000); + const TickType_t start = xTaskGetTickCount(); + bool anyPressed = true; + while (anyPressed && xTaskGetTickCount() - start < timeout) { + anyPressed = false; + for (Device* keyboard : devices_) { + KeyboardKeyData data; + if (device_is_ready(keyboard) && keyboard_read_key(keyboard, &data) == ERROR_NONE && data.pressed) { + anyPressed = true; + } + } + if (anyPressed) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + } +} + bool KeyboardInput::pump(KeyHandler onKey) { refresh(); @@ -29,10 +49,14 @@ bool KeyboardInput::pump(KeyHandler onKey) { } while (true) { if (keyboard_read_key(keyboard, &data) != ERROR_NONE || !data.pressed) { + heldKey_ = 0; break; } - if (onKey(data.key, data.ctrl, data.alt)) { - handled = true; + if (data.key != heldKey_) { + heldKey_ = data.key; + if (onKey(data.key, data.ctrl, data.alt)) { + handled = true; + } } if (!data.continue_reading) { break; From 79694f00f6dd13b99716a08dda86a803cc830532 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:37:13 +0100 Subject: [PATCH 05/12] Maps icons updated --- Data/system/app_icon/tactility.maps.svg | 2 +- Data/system/app_icon/tactility.maps_mono.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Data/system/app_icon/tactility.maps.svg b/Data/system/app_icon/tactility.maps.svg index 2830323f3..810b99812 100644 --- a/Data/system/app_icon/tactility.maps.svg +++ b/Data/system/app_icon/tactility.maps.svg @@ -1 +1 @@ - + diff --git a/Data/system/app_icon/tactility.maps_mono.svg b/Data/system/app_icon/tactility.maps_mono.svg index f57e88b1d..68f4615a0 100644 --- a/Data/system/app_icon/tactility.maps_mono.svg +++ b/Data/system/app_icon/tactility.maps_mono.svg @@ -1 +1 @@ - \ No newline at end of file + From 4a1b5d5b628946a6d7a943cdcb019d9c67cfb216 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:37:47 +0100 Subject: [PATCH 06/12] Add log app --- Modules/coreutils-module/source/apps/log.cpp | 137 ++++++++++++++++++ Modules/coreutils-module/source/module.cpp | 3 + TactilityKernel/include/tactility/log.h | 19 +++ TactilityKernel/source/log_queue.cpp | 50 +++++++ .../tests/source/log_listener_test.cpp | 49 +++++++ 5 files changed, 258 insertions(+) create mode 100644 Modules/coreutils-module/source/apps/log.cpp create mode 100644 TactilityKernel/tests/source/log_listener_test.cpp diff --git a/Modules/coreutils-module/source/apps/log.cpp b/Modules/coreutils-module/source/apps/log.cpp new file mode 100644 index 000000000..29aed71a3 --- /dev/null +++ b/Modules/coreutils-module/source/apps/log.cpp @@ -0,0 +1,137 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace coreutils::log { + +constexpr size_t RING_CAPACITY = 2048; +constexpr size_t CHUNK_SIZE = 512; +constexpr int POLL_INTERVAL_MS = 50; + +/** Hands lines from the log drain task to the app's task. It only holds what wasn't printed yet. */ +struct Ring { + Mutex mutex; + size_t head = 0; + size_t count = 0; + size_t dropped = 0; + char data[RING_CAPACITY]; +}; + +static volatile sig_atomic_t stopRequested = 0; + +static void onSignal(int) { + stopRequested = 1; +} + +/** Whole lines only: a line that doesn't fit is dropped and counted */ +static void onLogLine(const char* line, size_t length, void* context) { + auto* ring = static_cast(context); + mutex_lock(&ring->mutex); + if (length > RING_CAPACITY - ring->count) { + ring->dropped++; + } else { + size_t tail = (ring->head + ring->count) % RING_CAPACITY; + for (size_t i = 0; i < length; i++) { + ring->data[(tail + i) % RING_CAPACITY] = line[i]; + } + ring->count += length; + } + mutex_unlock(&ring->mutex); +} + +static void drain(Ring* ring) { + char chunk[CHUNK_SIZE]; + while (true) { + size_t length = 0; + size_t dropped = 0; + mutex_lock(&ring->mutex); + length = ring->count < sizeof(chunk) ? ring->count : sizeof(chunk); + for (size_t i = 0; i < length; i++) { + chunk[i] = ring->data[(ring->head + i) % RING_CAPACITY]; + } + ring->head = (ring->head + length) % RING_CAPACITY; + ring->count -= length; + dropped = ring->dropped; + ring->dropped = 0; + mutex_unlock(&ring->mutex); + + if (dropped > 0) { + printf("[%zu log lines dropped]\n", dropped); + } + if (length == 0) { + break; + } + fwrite(chunk, 1, length, stdout); + } + fflush(stdout); +} + +/** Returns true when any key was typed or stdin was closed */ +static bool waitForInput() { + pollfd descriptor = { .fd = STDIN_FILENO, .events = POLLIN, .revents = 0 }; + const int result = poll(&descriptor, 1, POLL_INTERVAL_MS); + if (result <= 0) { + return false; + } + if (descriptor.revents & (POLLHUP | POLLERR)) { + return true; + } + char input[16]; + read(STDIN_FILENO, input, sizeof(input)); + return true; +} + +static int32_t main(int, char*[]) { + MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + auto* ring = static_cast(memory_alloc_with_policy(sizeof(Ring), &policy)); + if (ring == nullptr) { + fprintf(stderr, "log: out of memory\n"); + return 1; + } + ring->head = 0; + ring->count = 0; + ring->dropped = 0; + mutex_construct(&ring->mutex); + + stopRequested = 0; + signal(SIGINT, onSignal); + signal(SIGTERM, onSignal); + signal(SIGHUP, onSignal); + + if (!log_subscribe(onLogLine, ring)) { + fprintf(stderr, "log: too many log listeners\n"); + mutex_destruct(&ring->mutex); + memory_free(ring); + return 1; + } + + while (!stopRequested) { + if (waitForInput()) { + break; + } + drain(ring); + } + + log_unsubscribe(onLogLine, ring); + mutex_destruct(&ring->mutex); + memory_free(ring); + return 0; +} + +extern const ::AppManifest manifest = { + .id = "log", + .name = "log", + .category = APP_CATEGORY_SYSTEM, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, + .flags = APP_MANIFEST_FLAG_HEADLESS, + .stack = { .depth = 4096, .desired_memory_capability = 0 }, +}; + +} // namespace coreutils::log diff --git a/Modules/coreutils-module/source/module.cpp b/Modules/coreutils-module/source/module.cpp index 1fae962a6..174bb6967 100644 --- a/Modules/coreutils-module/source/module.cpp +++ b/Modules/coreutils-module/source/module.cpp @@ -16,6 +16,7 @@ namespace coreutils { namespace free { extern const ::AppManifest manifest; } namespace head { extern const ::AppManifest manifest; } namespace help { extern const ::AppManifest manifest; } + namespace log { extern const ::AppManifest manifest; } namespace ls { extern const ::AppManifest manifest; } namespace mkdir { extern const ::AppManifest manifest; } namespace mv { extern const ::AppManifest manifest; } @@ -40,6 +41,7 @@ static error_t start() { if ((error = app_manager_add(&coreutils::free::manifest)) != ERROR_NONE) return error; if ((error = app_manager_add(&coreutils::head::manifest)) != ERROR_NONE) return error; if ((error = app_manager_add(&coreutils::help::manifest)) != ERROR_NONE) return error; + if ((error = app_manager_add(&coreutils::log::manifest)) != ERROR_NONE) return error; if ((error = app_manager_add(&coreutils::ls::manifest)) != ERROR_NONE) return error; if ((error = app_manager_add(&coreutils::mkdir::manifest)) != ERROR_NONE) return error; if ((error = app_manager_add(&coreutils::mv::manifest)) != ERROR_NONE) return error; @@ -62,6 +64,7 @@ static error_t stop() { app_manager_remove(coreutils::free::manifest.id); app_manager_remove(coreutils::head::manifest.id); app_manager_remove(coreutils::help::manifest.id); + app_manager_remove(coreutils::log::manifest.id); app_manager_remove(coreutils::ls::manifest.id); app_manager_remove(coreutils::mkdir::manifest.id); app_manager_remove(coreutils::mv::manifest.id); diff --git a/TactilityKernel/include/tactility/log.h b/TactilityKernel/include/tactility/log.h index e9665dd00..302835cc6 100644 --- a/TactilityKernel/include/tactility/log.h +++ b/TactilityKernel/include/tactility/log.h @@ -6,6 +6,9 @@ #include #endif +#include +#include + #ifdef __cplusplus extern "C" { #endif @@ -19,6 +22,22 @@ enum LogLevel { LOG_LEVEL_VERBOSE /*!< Bigger chunks of debugging information, or frequent messages which can potentially flood the output. */ }; +/** + * Receives every formatted log line, including colour codes and the trailing newline. + * Called on the log drain task with the listener registry locked, so it must be quick, + * must not block, must not log and must not call log_subscribe() or log_unsubscribe(). + */ +typedef void (*LogListener)(const char* line, size_t length, void* context); + +/** + * Registers a listener. Nothing is stored while there are no listeners. + * @retval false when all listener slots are taken or the log queue isn't initialized + */ +bool log_subscribe(LogListener listener, void* context); + +/** Removes a listener. When this returns, the listener is not running and won't be called again. */ +void log_unsubscribe(LogListener listener, void* context); + #ifndef ESP_PLATFORM void log_generic(enum LogLevel level, const char* tag, const char* format, ...); diff --git a/TactilityKernel/source/log_queue.cpp b/TactilityKernel/source/log_queue.cpp index 26fe440c1..b08e39755 100644 --- a/TactilityKernel/source/log_queue.cpp +++ b/TactilityKernel/source/log_queue.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -27,6 +28,26 @@ struct LogQueueMessage { std::atomic g_queue { nullptr }; +constexpr size_t LISTENER_CAPACITY = 4; + +struct ListenerSlot { + LogListener listener; + void* context; +}; + +Mutex g_listener_mutex; +ListenerSlot g_listeners[LISTENER_CAPACITY] = {}; + +void notify_listeners(const char* data, size_t length) { + mutex_lock(&g_listener_mutex); + for (const auto& slot : g_listeners) { + if (slot.listener != nullptr) { + slot.listener(data, length, slot.context); + } + } + mutex_unlock(&g_listener_mutex); +} + #if !defined(ESP_PLATFORM) thread_local bool g_direct_write = false; #endif @@ -60,6 +81,7 @@ void drain_task_main(void* context) { while (true) { if (xQueueReceive(queue, &message, portMAX_DELAY) == pdTRUE) { write_real(message.text, message.length); + notify_listeners(message.text, message.length); } } } @@ -160,6 +182,7 @@ void log_queue_init(void) { } #endif + mutex_construct(&g_listener_mutex); g_queue.store(queue, std::memory_order_release); // published last #if defined(ESP_PLATFORM) @@ -167,6 +190,33 @@ void log_queue_init(void) { #endif } +bool log_subscribe(LogListener listener, void* context) { + if (g_queue.load(std::memory_order_acquire) == nullptr) { + return false; + } + bool added = false; + mutex_lock(&g_listener_mutex); + for (auto& slot : g_listeners) { + if (slot.listener == nullptr) { + slot = { listener, context }; + added = true; + break; + } + } + mutex_unlock(&g_listener_mutex); + return added; +} + +void log_unsubscribe(LogListener listener, void* context) { + mutex_lock(&g_listener_mutex); + for (auto& slot : g_listeners) { + if (slot.listener == listener && slot.context == context) { + slot = {}; + } + } + mutex_unlock(&g_listener_mutex); +} + #if !defined(ESP_PLATFORM) void log_set_direct_write_for_current_thread(void) { g_direct_write = true; diff --git a/TactilityKernel/tests/source/log_listener_test.cpp b/TactilityKernel/tests/source/log_listener_test.cpp new file mode 100644 index 000000000..72575c3ec --- /dev/null +++ b/TactilityKernel/tests/source/log_listener_test.cpp @@ -0,0 +1,49 @@ +#include + +#include +#include + +#include + +#include +#include +#include + +namespace { + +struct Received { + std::atomic count { 0 }; + std::string last; +}; + +void onLine(const char* line, size_t length, void* context) { + auto* received = static_cast(context); + received->last.assign(line, length); + received->count++; +} + +bool waitForCount(Received& received, int expected) { + for (int i = 0; i < 100 && received.count < expected; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + return received.count >= expected; +} + +} + +TEST_CASE("log listener receives lines until unsubscribed") { + log_queue_init(); + Received received; + REQUIRE(log_subscribe(onLine, &received)); + + const char* line = "hello\n"; + log_queue_write(line, strlen(line)); + REQUIRE(waitForCount(received, 1)); + CHECK(received.last == "hello\n"); + + log_unsubscribe(onLine, &received); + const int countAfterUnsubscribe = received.count; + log_queue_write(line, strlen(line)); + vTaskDelay(pdMS_TO_TICKS(100)); + CHECK(received.count == countAfterUnsubscribe); +} From 840263c82ef122af3caf3ca052370e315cb7edf5 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:37:50 +0100 Subject: [PATCH 07/12] Fix warning --- Tactility/Source/app/files/View.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/Tactility/Source/app/files/View.cpp b/Tactility/Source/app/files/View.cpp index e1bc5da9e..310ab9214 100644 --- a/Tactility/Source/app/files/View.cpp +++ b/Tactility/Source/app/files/View.cpp @@ -386,6 +386,7 @@ View::View(const std::shared_ptr& state, TaskEventGroup* eventGroup) : .resolveImage = resolveImage, .resolveIcon = resolveIcon, .onPageChanged = onPageChanged, + .onTilesCleared = nullptr, .userData = this }) { check(task_event_group_claim_bit(eventGroup, &pasteBit) == ERROR_NONE); From 181f610569f297c1d4645237880a9ee78d0711f7 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:44:05 +0100 Subject: [PATCH 08/12] Larger pin in maps mono icon --- Data/system/app_icon/tactility.maps_mono.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Data/system/app_icon/tactility.maps_mono.svg b/Data/system/app_icon/tactility.maps_mono.svg index 68f4615a0..783dc5f75 100644 --- a/Data/system/app_icon/tactility.maps_mono.svg +++ b/Data/system/app_icon/tactility.maps_mono.svg @@ -1 +1 @@ - + From e5f3ef727d5f6a5d402a6965f8b529eaf623d0e8 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 14:44:15 +0100 Subject: [PATCH 09/12] Remove screenshots, simplify readme.md --- Documentation/pics/screenshot-Files.png | Bin 3194 -> 0 bytes Documentation/pics/screenshot-Launcher.png | Bin 4064 -> 0 bytes Documentation/pics/screenshot-Settings.png | Bin 4489 -> 0 bytes Documentation/pics/screenshot-Terminal.png | Bin 1998 -> 0 bytes README.md | 9 ++++----- 5 files changed, 4 insertions(+), 5 deletions(-) delete mode 100644 Documentation/pics/screenshot-Files.png delete mode 100644 Documentation/pics/screenshot-Launcher.png delete mode 100644 Documentation/pics/screenshot-Settings.png delete mode 100644 Documentation/pics/screenshot-Terminal.png diff --git a/Documentation/pics/screenshot-Files.png b/Documentation/pics/screenshot-Files.png deleted file mode 100644 index e6a57990249358f1ab60cfe30d94bc0a5fb99807..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3194 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 diff --git a/Documentation/pics/screenshot-Launcher.png b/Documentation/pics/screenshot-Launcher.png deleted file mode 100644 index dfdfc183a21f5bf4effe3127f0694da9096767d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4064 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 M(#?T!Jm` -
screenshot of launcher app   screenshot of settings app
-
screenshot of terminal app   screenshot of files app
- +- [Main website](https://tactilityproject.org) +- [Installer](https://install.tactilityproject.org) +- [Documentation](https://docs.tactilityproject.org) ## License From 358afeae2d6d324009d76ad6cf9e70d4deab32b1 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 15:07:53 +0100 Subject: [PATCH 10/12] Fixes --- Modules/coreutils-module/source/apps/log.cpp | 26 ++++++---- .../Tactility/app/terminal/KeyboardInput.h | 10 ++-- Tactility/Source/app/meshchat/MeshApp.cpp | 47 ++++++++++++++----- .../Source/app/terminal/KeyboardInput.cpp | 44 ++++++++++------- .../tests/source/log_listener_test.cpp | 8 ++++ 5 files changed, 93 insertions(+), 42 deletions(-) diff --git a/Modules/coreutils-module/source/apps/log.cpp b/Modules/coreutils-module/source/apps/log.cpp index 29aed71a3..0e7f03d7a 100644 --- a/Modules/coreutils-module/source/apps/log.cpp +++ b/Modules/coreutils-module/source/apps/log.cpp @@ -4,8 +4,10 @@ #include #include +#include #include #include +#include #include #include @@ -20,7 +22,8 @@ struct Ring { Mutex mutex; size_t head = 0; size_t count = 0; - size_t dropped = 0; + /** Lines that were dropped, also when the callback couldn't get the mutex */ + std::atomic dropped { 0 }; char data[RING_CAPACITY]; }; @@ -30,10 +33,13 @@ static void onSignal(int) { stopRequested = 1; } -/** Whole lines only: a line that doesn't fit is dropped and counted */ +/** Never waits, so logging isn't held up. Whole lines only: a line that doesn't fit, or arrives while the ring is busy, is dropped and counted. */ static void onLogLine(const char* line, size_t length, void* context) { auto* ring = static_cast(context); - mutex_lock(&ring->mutex); + if (!mutex_try_lock(&ring->mutex, 0)) { + ring->dropped++; + return; + } if (length > RING_CAPACITY - ring->count) { ring->dropped++; } else { @@ -46,11 +52,12 @@ static void onLogLine(const char* line, size_t length, void* context) { mutex_unlock(&ring->mutex); } +/** Writes at most one ring's worth, so the main loop keeps checking for input while logging is busy */ static void drain(Ring* ring) { char chunk[CHUNK_SIZE]; - while (true) { + size_t written = 0; + while (written < RING_CAPACITY) { size_t length = 0; - size_t dropped = 0; mutex_lock(&ring->mutex); length = ring->count < sizeof(chunk) ? ring->count : sizeof(chunk); for (size_t i = 0; i < length; i++) { @@ -58,10 +65,10 @@ static void drain(Ring* ring) { } ring->head = (ring->head + length) % RING_CAPACITY; ring->count -= length; - dropped = ring->dropped; - ring->dropped = 0; mutex_unlock(&ring->mutex); + const size_t dropped = ring->dropped.exchange(0); + if (dropped > 0) { printf("[%zu log lines dropped]\n", dropped); } @@ -69,6 +76,7 @@ static void drain(Ring* ring) { break; } fwrite(chunk, 1, length, stdout); + written += length; } fflush(stdout); } @@ -95,9 +103,7 @@ static int32_t main(int, char*[]) { fprintf(stderr, "log: out of memory\n"); return 1; } - ring->head = 0; - ring->count = 0; - ring->dropped = 0; + new (ring) Ring(); mutex_construct(&ring->mutex); stopRequested = 0; diff --git a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h index 7e61ba65b..dbfa577f9 100644 --- a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h +++ b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h @@ -30,6 +30,12 @@ class KeyboardInput { private: static constexpr uint32_t REFRESH_INTERVAL_MS = 1000; + struct Keyboard { + Device* device; + /** Keyboards may keep reporting a held key as pressed, so a key only counts on its press edge */ + uint32_t heldKey = 0; + }; + static bool collect(Device* device, void* context); void refresh(); @@ -37,9 +43,7 @@ class KeyboardInput { void applyExclusive(); void awaitKeyRelease(); - std::vector devices_; + std::vector devices_; TickType_t lastRefresh_ = 0; bool exclusive_ = false; - /** Keyboards may keep reporting a held key as pressed, so a key only counts on its press edge */ - uint32_t heldKey_ = 0; }; diff --git a/Tactility/Source/app/meshchat/MeshApp.cpp b/Tactility/Source/app/meshchat/MeshApp.cpp index b144b5aa1..b98c1284e 100644 --- a/Tactility/Source/app/meshchat/MeshApp.cpp +++ b/Tactility/Source/app/meshchat/MeshApp.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include namespace tt::app::meshchat { @@ -95,22 +96,40 @@ struct Context { NetworkInfo network; }; +/** What loadData() fetched, committed to the context by commitData() */ +struct Snapshot { + NetworkInfo network; + bool canAddChannel = false; + tt::std::vector channels; + tt::std::vector contacts; +}; + /** Queries the service thread, so it's called without the LVGL lock */ -void loadData(Context* ctx) { - ctx->network = loadNetworkInfo(); - ctx->canAddChannel = mesh_supports(MESH_FEATURE_NAMED_CHANNELS) || mesh_supports(MESH_FEATURE_KEYED_CHANNELS); - ctx->channels.assign(CHANNEL_LIMIT, MeshChannel {}); +Snapshot loadData() { + Snapshot snapshot; + snapshot.network = loadNetworkInfo(); + snapshot.canAddChannel = mesh_supports(MESH_FEATURE_NAMED_CHANNELS) || mesh_supports(MESH_FEATURE_KEYED_CHANNELS); + snapshot.channels.assign(CHANNEL_LIMIT, MeshChannel {}); size_t count = 0; - if (mesh_get_channels(ctx->channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) { + if (mesh_get_channels(snapshot.channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) { count = 0; } - ctx->channels.resize(count); - ctx->contacts.assign(CONTACT_LIMIT, MeshContact {}); + snapshot.channels.resize(count); + snapshot.contacts.assign(CONTACT_LIMIT, MeshContact {}); count = 0; - if (mesh_get_contacts(ctx->contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { + if (mesh_get_contacts(snapshot.contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { count = 0; } - ctx->contacts.resize(count); + snapshot.contacts.resize(count); + return snapshot; +} + +/** Requires the LVGL lock, because create_widgets can run on another thread and reads the cached state */ +void commitData(Context* ctx, Snapshot&& snapshot) { + ctx->network = snapshot.network; + ctx->canAddChannel = snapshot.canAddChannel; + ctx->channels = ::std::move(snapshot.channels); + ctx->contacts = ::std::move(snapshot.contacts); } lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback, void* callbackData, intptr_t index) { @@ -551,7 +570,12 @@ int32_t appMain(int argc, char* argv[]) { MeshEventSubscription meshEvents {}; check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); - loadData(&ctx); + { + Snapshot snapshot = loadData(); + lvgl_lock(); + commitData(&ctx, ::std::move(snapshot)); + lvgl_unlock(); + } WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); bool shouldClose = false; @@ -632,8 +656,9 @@ int32_t appMain(int argc, char* argv[]) { } if (changed) { - loadData(&ctx); + Snapshot snapshot = loadData(); lvgl_lock(); + commitData(&ctx, ::std::move(snapshot)); updateWidgets(&ctx); lvgl_unlock(); } diff --git a/Tactility/Source/app/terminal/KeyboardInput.cpp b/Tactility/Source/app/terminal/KeyboardInput.cpp index 8749307d0..af8493f2d 100644 --- a/Tactility/Source/app/terminal/KeyboardInput.cpp +++ b/Tactility/Source/app/terminal/KeyboardInput.cpp @@ -12,10 +12,12 @@ KeyboardInput::KeyboardInput() { } KeyboardInput::~KeyboardInput() { - awaitKeyRelease(); - setExclusive(false); - for (Device* device : devices_) { - device_put(device); + if (exclusive_) { + awaitKeyRelease(); + setExclusive(false); + } + for (const Keyboard& keyboard : devices_) { + device_put(keyboard.device); } } @@ -26,9 +28,9 @@ void KeyboardInput::awaitKeyRelease() { bool anyPressed = true; while (anyPressed && xTaskGetTickCount() - start < timeout) { anyPressed = false; - for (Device* keyboard : devices_) { + for (const Keyboard& entry : devices_) { KeyboardKeyData data; - if (device_is_ready(keyboard) && keyboard_read_key(keyboard, &data) == ERROR_NONE && data.pressed) { + if (device_is_ready(entry.device) && keyboard_read_key(entry.device, &data) == ERROR_NONE && data.pressed) { anyPressed = true; } } @@ -43,17 +45,17 @@ bool KeyboardInput::pump(KeyHandler onKey) { bool handled = false; KeyboardKeyData data; - for (Device* keyboard : devices_) { - if (!device_is_ready(keyboard)) { + for (Keyboard& keyboard : devices_) { + if (!device_is_ready(keyboard.device)) { continue; } while (true) { - if (keyboard_read_key(keyboard, &data) != ERROR_NONE || !data.pressed) { - heldKey_ = 0; + if (keyboard_read_key(keyboard.device, &data) != ERROR_NONE || !data.pressed) { + keyboard.heldKey = 0; break; } - if (data.key != heldKey_) { - heldKey_ = data.key; + if (data.key != keyboard.heldKey) { + keyboard.heldKey = data.key; if (onKey(data.key, data.ctrl, data.alt)) { handled = true; } @@ -72,6 +74,12 @@ void KeyboardInput::setExclusive(bool exclusive) { return; } exclusive_ = exclusive; + if (!exclusive) { + // pump() doesn't run while hidden, so a release during that time is never seen + for (Keyboard& keyboard : devices_) { + keyboard.heldKey = 0; + } + } applyExclusive(); } @@ -80,8 +88,8 @@ void KeyboardInput::applyExclusive() { return; } lvgl_lock(); - for (Device* device : devices_) { - lv_indev_t* indev = lvgl_keyboard_find_by_device(device); + for (const Keyboard& keyboard : devices_) { + lv_indev_t* indev = lvgl_keyboard_find_by_device(keyboard.device); if (indev != nullptr) { lv_indev_enable(indev, !exclusive_); } @@ -110,16 +118,16 @@ void KeyboardInput::rescan() { for (Device* device : found) { if ( device_is_ready(device) && - std::find(devices_.begin(), devices_.end(), device) == devices_.end() && + std::find_if(devices_.begin(), devices_.end(), [device](const Keyboard& keyboard) { return keyboard.device == device; }) == devices_.end() && device_get(device) == ERROR_NONE ) { - devices_.push_back(device); + devices_.push_back({ device }); LOG_I(TAG, "Found keyboard: %s", device->name); } } for (auto it = devices_.begin(); it != devices_.end();) { - if (std::find(found.begin(), found.end(), *it) == found.end()) { - device_put(*it); + if (std::find(found.begin(), found.end(), it->device) == found.end()) { + device_put(it->device); it = devices_.erase(it); LOG_I(TAG, "Removed keyboard"); } else { diff --git a/TactilityKernel/tests/source/log_listener_test.cpp b/TactilityKernel/tests/source/log_listener_test.cpp index 72575c3ec..7ef829090 100644 --- a/TactilityKernel/tests/source/log_listener_test.cpp +++ b/TactilityKernel/tests/source/log_listener_test.cpp @@ -22,6 +22,13 @@ void onLine(const char* line, size_t length, void* context) { received->count++; } +/** Removes the listener when a failing assertion ends the test early */ +struct Subscription { + Received* received; + + ~Subscription() { log_unsubscribe(onLine, received); } +}; + bool waitForCount(Received& received, int expected) { for (int i = 0; i < 100 && received.count < expected; i++) { vTaskDelay(pdMS_TO_TICKS(10)); @@ -35,6 +42,7 @@ TEST_CASE("log listener receives lines until unsubscribed") { log_queue_init(); Received received; REQUIRE(log_subscribe(onLine, &received)); + Subscription subscription { &received }; const char* line = "hello\n"; log_queue_write(line, strlen(line)); From 960b928c4d5038f8c8d675dc42c32ecb67b90f41 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 15:28:50 +0100 Subject: [PATCH 11/12] Fixes and improvements --- .../Tactility/app/terminal/KeyboardInput.h | 17 +++-- Tactility/Private/Tactility/lvgl/Fonts.h | 6 +- .../Source/app/terminal/KeyboardInput.cpp | 62 ++++++++++++++++--- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h index dbfa577f9..89528fa0a 100644 --- a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h +++ b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h @@ -4,8 +4,7 @@ #include #include - -#include +#include /** Tracks every KEYBOARD_TYPE device, re-scanning at most once a second so a USB keyboard * plugged/unplugged after the terminal starts is picked up without restarting the app. */ @@ -30,10 +29,18 @@ class KeyboardInput { private: static constexpr uint32_t REFRESH_INTERVAL_MS = 1000; + /** Keyboards may keep reporting a held key as pressed, so a key only counts on its press edge */ struct Keyboard { + static constexpr size_t HELD_CAPACITY = 8; + Device* device; - /** Keyboards may keep reporting a held key as pressed, so a key only counts on its press edge */ - uint32_t heldKey = 0; + uint32_t held[HELD_CAPACITY] = {}; + size_t heldCount = 0; + + bool isHeld(uint32_t key) const; + void addHeld(uint32_t key); + void removeHeld(uint32_t key); + void clearHeld() { heldCount = 0; } }; static bool collect(Device* device, void* context); @@ -43,7 +50,7 @@ class KeyboardInput { void applyExclusive(); void awaitKeyRelease(); - std::vector devices_; + tt::std::vector devices_; TickType_t lastRefresh_ = 0; bool exclusive_ = false; }; diff --git a/Tactility/Private/Tactility/lvgl/Fonts.h b/Tactility/Private/Tactility/lvgl/Fonts.h index 159459566..cbeba0f4b 100644 --- a/Tactility/Private/Tactility/lvgl/Fonts.h +++ b/Tactility/Private/Tactility/lvgl/Fonts.h @@ -13,9 +13,9 @@ struct FontConfiguration { /** Default text font size in pixels, other font sizes are derived from it */ uint16_t defaultSize; /** TrueType font for regular text, or empty for the system font */ - std::string regularFontPath; + ::std::string regularFontPath; /** TrueType font for monospace text, or empty for the system font */ - std::string monoFontPath; + ::std::string monoFontPath; bool operator==(const FontConfiguration&) const = default; }; @@ -27,7 +27,7 @@ struct FontConfiguration { constexpr size_t LARGE_FONT_CHARACTER_COUNT = 1000; /** @return the number of characters in a TrueType font, or 0 when it can't be read */ -size_t getTtfCharacterCount(const std::string& path); +size_t getTtfCharacterCount(const ::std::string& path); /** * Changing fonts generates them on the device, which needs at least 2 MB of memory in total. diff --git a/Tactility/Source/app/terminal/KeyboardInput.cpp b/Tactility/Source/app/terminal/KeyboardInput.cpp index af8493f2d..29b37c0ca 100644 --- a/Tactility/Source/app/terminal/KeyboardInput.cpp +++ b/Tactility/Source/app/terminal/KeyboardInput.cpp @@ -4,6 +4,7 @@ #include #include +#include constexpr auto* TAG = "terminal-keyb"; @@ -11,6 +12,25 @@ KeyboardInput::KeyboardInput() { rescan(); } +bool KeyboardInput::Keyboard::isHeld(uint32_t key) const { + return std::find(held, held + heldCount, key) != held + heldCount; +} + +void KeyboardInput::Keyboard::addHeld(uint32_t key) { + if (heldCount < HELD_CAPACITY) { + held[heldCount++] = key; + } +} + +void KeyboardInput::Keyboard::removeHeld(uint32_t key) { + for (size_t i = 0; i < heldCount; i++) { + if (held[i] == key) { + held[i] = held[--heldCount]; + return; + } + } +} + KeyboardInput::~KeyboardInput() { if (exclusive_) { awaitKeyRelease(); @@ -21,6 +41,28 @@ KeyboardInput::~KeyboardInput() { } } +/** + * Drains the queued events of a keyboard, which ends in an idle read when nothing is down, + * or in a pressed read when a key is still down. + * @return true when a key is still down + */ +static bool isAnyKeyDown(Device* keyboard) { + constexpr int maxReads = 64; + KeyboardKeyData data; + for (int i = 0; i < maxReads; i++) { + if (keyboard_read_key(keyboard, &data) != ERROR_NONE) { + return false; + } + if (data.pressed && !data.continue_reading) { + return true; + } + if (!data.pressed && data.key == 0 && !data.continue_reading) { + return false; + } + } + return false; +} + /** A key that is still down would otherwise be seen as a fresh press by the LVGL indevs that are enabled again */ void KeyboardInput::awaitKeyRelease() { constexpr TickType_t timeout = pdMS_TO_TICKS(1000); @@ -29,8 +71,7 @@ void KeyboardInput::awaitKeyRelease() { while (anyPressed && xTaskGetTickCount() - start < timeout) { anyPressed = false; for (const Keyboard& entry : devices_) { - KeyboardKeyData data; - if (device_is_ready(entry.device) && keyboard_read_key(entry.device, &data) == ERROR_NONE && data.pressed) { + if (device_is_ready(entry.device) && isAnyKeyDown(entry.device)) { anyPressed = true; } } @@ -50,12 +91,19 @@ bool KeyboardInput::pump(KeyHandler onKey) { continue; } while (true) { - if (keyboard_read_key(keyboard.device, &data) != ERROR_NONE || !data.pressed) { - keyboard.heldKey = 0; + if (keyboard_read_key(keyboard.device, &data) != ERROR_NONE) { + keyboard.clearHeld(); break; } - if (data.key != keyboard.heldKey) { - keyboard.heldKey = data.key; + if (!data.pressed) { + if (data.key == 0) { + // Nothing is down + keyboard.clearHeld(); + break; + } + keyboard.removeHeld(data.key); + } else if (!keyboard.isHeld(data.key)) { + keyboard.addHeld(data.key); if (onKey(data.key, data.ctrl, data.alt)) { handled = true; } @@ -77,7 +125,7 @@ void KeyboardInput::setExclusive(bool exclusive) { if (!exclusive) { // pump() doesn't run while hidden, so a release during that time is never seen for (Keyboard& keyboard : devices_) { - keyboard.heldKey = 0; + keyboard.clearHeld(); } } applyExclusive(); From be7d5d5ef5cfaff5e6f40f35aef932b8adefe3bd Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 14 Nov 2026 15:46:25 +0100 Subject: [PATCH 12/12] Scale icon<>text gap --- Tactility/Source/app/TileGrid.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tactility/Source/app/TileGrid.cpp b/Tactility/Source/app/TileGrid.cpp index 7d584491a..8472b00db 100644 --- a/Tactility/Source/app/TileGrid.cpp +++ b/Tactility/Source/app/TileGrid.cpp @@ -46,7 +46,7 @@ PageLayout computePageLayout(lv_obj_t* grid) { const auto pad = icon_size / 16; // The focus ring is drawn inside the frame, at most 2dp wide, and leaves 1dp to the icon const auto frame_pad = LV_DPX(2) + LV_DPX(1); - const auto label_gap = LV_DPX(2); + const auto label_gap = lvgl_theme_lerp(LV_DPX(1), LV_DPX(4), lvgl_theme_get_spacing()); const auto gap = TILE_GAP; const auto text_height = lv_font_get_line_height(lvgl_get_text_font(FONT_SIZE_SMALL)); // The label needs room for a few characters, also when the icon font is smaller (e.g. a fallback)