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..783dc5f75 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 + 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/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; diff --git a/Documentation/pics/screenshot-Files.png b/Documentation/pics/screenshot-Files.png deleted file mode 100644 index e6a579902..000000000 Binary files a/Documentation/pics/screenshot-Files.png and /dev/null differ diff --git a/Documentation/pics/screenshot-Launcher.png b/Documentation/pics/screenshot-Launcher.png deleted file mode 100644 index dfdfc183a..000000000 Binary files a/Documentation/pics/screenshot-Launcher.png and /dev/null differ diff --git a/Documentation/pics/screenshot-Settings.png b/Documentation/pics/screenshot-Settings.png deleted file mode 100644 index bc92adeaf..000000000 Binary files a/Documentation/pics/screenshot-Settings.png and /dev/null differ diff --git a/Documentation/pics/screenshot-Terminal.png b/Documentation/pics/screenshot-Terminal.png deleted file mode 100644 index 01a46e03c..000000000 Binary files a/Documentation/pics/screenshot-Terminal.png and /dev/null differ diff --git a/Modules/coreutils-module/source/apps/log.cpp b/Modules/coreutils-module/source/apps/log.cpp new file mode 100644 index 000000000..0e7f03d7a --- /dev/null +++ b/Modules/coreutils-module/source/apps/log.cpp @@ -0,0 +1,143 @@ +#include + +#include +#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; + /** Lines that were dropped, also when the callback couldn't get the mutex */ + std::atomic dropped { 0 }; + char data[RING_CAPACITY]; +}; + +static volatile sig_atomic_t stopRequested = 0; + +static void onSignal(int) { + stopRequested = 1; +} + +/** 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); + if (!mutex_try_lock(&ring->mutex, 0)) { + ring->dropped++; + return; + } + 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); +} + +/** 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]; + size_t written = 0; + while (written < RING_CAPACITY) { + size_t length = 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; + mutex_unlock(&ring->mutex); + + const size_t dropped = ring->dropped.exchange(0); + + if (dropped > 0) { + printf("[%zu log lines dropped]\n", dropped); + } + if (length == 0) { + break; + } + fwrite(chunk, 1, length, stdout); + written += length; + } + 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; + } + new (ring) Ring(); + 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/README.md b/README.md index 53b793ae0..6864aa6b2 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,11 @@ Tactility is an operating system that focuses on the ESP32 microcontroller family. It can load apps from SD card that were built with native C/C++ SDKs. -See [https://tactilityproject.org](https://tactilityproject.org) for more information. +Links: -
-
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 diff --git a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h index beda05d39..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,13 +29,28 @@ 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; + 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); void refresh(); void rescan(); 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/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/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) 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); 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 56% rename from Tactility/Source/app/meshchat/MeshChannels.cpp rename to Tactility/Source/app/meshchat/MeshApp.cpp index 5ad7f5dc4..b98c1284e 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 @@ -27,36 +30,45 @@ #include #include #include +#include #include 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,23 +80,56 @@ 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; +}; + +/** 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; + } + snapshot.channels.resize(count); + snapshot.contacts.assign(CONTACT_LIMIT, MeshContact {}); + count = 0; + if (mesh_get_contacts(snapshot.contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { count = 0; } - ctx->channels.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) { @@ -94,21 +139,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 +166,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; + } - ctx->networkHeader = createNetworkHeader(parent); + 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 +226,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 +443,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,15 +559,23 @@ 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); 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; @@ -370,27 +586,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 +640,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; } @@ -417,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(); } @@ -433,9 +673,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 +683,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 - diff --git a/Tactility/Source/app/terminal/KeyboardInput.cpp b/Tactility/Source/app/terminal/KeyboardInput.cpp index 368f1120b..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,10 +12,72 @@ 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() { - setExclusive(false); - for (Device* device : devices_) { - device_put(device); + if (exclusive_) { + awaitKeyRelease(); + setExclusive(false); + } + for (const Keyboard& keyboard : devices_) { + device_put(keyboard.device); + } +} + +/** + * 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); + const TickType_t start = xTaskGetTickCount(); + bool anyPressed = true; + while (anyPressed && xTaskGetTickCount() - start < timeout) { + anyPressed = false; + for (const Keyboard& entry : devices_) { + if (device_is_ready(entry.device) && isAnyKeyDown(entry.device)) { + anyPressed = true; + } + } + if (anyPressed) { + vTaskDelay(pdMS_TO_TICKS(10)); + } } } @@ -23,16 +86,27 @@ 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) { + if (keyboard_read_key(keyboard.device, &data) != ERROR_NONE) { + keyboard.clearHeld(); break; } - if (onKey(data.key, data.ctrl, data.alt)) { - handled = true; + 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; + } } if (!data.continue_reading) { break; @@ -48,6 +122,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.clearHeld(); + } + } applyExclusive(); } @@ -56,8 +136,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_); } @@ -86,16 +166,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/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(); 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..7ef829090 --- /dev/null +++ b/TactilityKernel/tests/source/log_listener_test.cpp @@ -0,0 +1,57 @@ +#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++; +} + +/** 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)); + } + return received.count >= expected; +} + +} + +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)); + 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); +}