#include "uconsole/uconsole_desktop_shell.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "app/input_event.h" #include "app/linux_app_services.h" #include "core/canvas.h" #include "lvgl.h" #include "platform/ui/gps_runtime.h" #include "uconsole/uconsole_chat_workspace_model.h" #include "uconsole/uconsole_dashboard_model.h" #include "uconsole/uconsole_map_workspace_model.h" #include "ui_map_runtime/map_tiles/map_tile_types.h" namespace trailmate::uconsole { namespace { using clock = std::chrono::steady_clock; using InputEvent = cardputer_zero::app::InputEvent; using InputKey = cardputer_zero::app::InputKey; using Canvas = cardputer_zero::core::Canvas; constexpr int kConversationRows = 6; constexpr int kContactRows = 7; constexpr int kMetricCount = 4; constexpr int kCapabilityRows = 5; constexpr int kChatConversationRows = 7; constexpr int kChatMessageRows = 7; constexpr std::uint32_t kLvglFunctionKeyF1 = 0x110001U; constexpr int kMapTileDisplaySize = 150; constexpr std::size_t kMaxMapPreviewTiles = 121; namespace embedded_palette { constexpr std::uint32_t kPageBg = 0xFFF3DF; constexpr std::uint32_t kSurface = 0xFFF7E9; constexpr std::uint32_t kSurfaceAlt = 0xFFF0D3; constexpr std::uint32_t kPanelBg = 0xFAF0D8; constexpr std::uint32_t kBorder = 0xD9B06A; constexpr std::uint32_t kSeparator = 0xE8D2AB; constexpr std::uint32_t kAccent = 0xEBA341; constexpr std::uint32_t kAccentDark = 0xC98118; constexpr std::uint32_t kHeaderText = 0x2A1A05; constexpr std::uint32_t kText = 0x3A2A1A; constexpr std::uint32_t kTextWarm = 0x6B4A1E; constexpr std::uint32_t kTextMuted = 0x6A5646; constexpr std::uint32_t kTextDim = 0x8A6A3A; constexpr std::uint32_t kWhite = 0xFFFFFF; constexpr std::uint32_t kStatusGreen = 0x5BAF4A; constexpr std::uint32_t kStatusBlue = 0x2F6FD6; constexpr std::uint32_t kWarn = 0xB94A2C; constexpr std::uint32_t kError = 0xCC0000; constexpr std::uint32_t kSoftAmber = 0xF3D39C; constexpr std::uint32_t kSoftBlue = 0xDCE8F7; constexpr std::uint32_t kSoftGreen = 0xDCEFD8; constexpr std::uint32_t kSoftWarn = 0xF5D9D1; constexpr std::uint32_t kMapBg = 0xF6E7C8; constexpr std::uint32_t kPlotBg = 0xF2E4C8; constexpr std::uint32_t kMapTile1 = 0xEAD9B2; constexpr std::uint32_t kMapTile2 = 0xF2E4C8; constexpr std::uint32_t kMapTile3 = 0xE4D2AA; constexpr std::uint32_t kMapTile4 = 0xE0C894; constexpr std::uint32_t kMapTile5 = 0xEEDBB4; constexpr std::uint32_t kMapTile6 = 0xE7D3A4; constexpr std::uint32_t kMapTile7 = 0xF3E2BE; constexpr std::uint32_t kMapTile8 = 0xDEC58F; } // namespace embedded_palette constexpr std::array kNavLabels{ "Overview", "Chat", "Map", "Contacts", "GPS & sky plot", "Team", "Tracker", "Radio tools", "Hardware", "Data & maps", "Extensions", "Logs", "Settings", }; std::chrono::steady_clock::time_point g_lvgl_start_time = clock::now(); enum class Section : std::uint8_t { Overview = 0, Chat, Map, Contacts, Gps, Team, Tracker, RadioTools, Hardware, Data, Extensions, Logs, Settings, }; struct QueuedKeyEvent { std::uint32_t key = 0; lv_indev_state_t state = LV_INDEV_STATE_RELEASED; }; [[nodiscard]] std::uint32_t tickNow() noexcept { return static_cast( std::chrono::duration_cast( clock::now() - g_lvgl_start_time) .count()); } [[nodiscard]] std::uint32_t mapInputEvent(const InputEvent& event) noexcept { switch (event.key) { case InputKey::Character: return event.text == '\0' ? 0U : static_cast(event.text); case InputKey::Backspace: return LV_KEY_BACKSPACE; case InputKey::Enter: return LV_KEY_ENTER; case InputKey::Tab: return LV_KEY_NEXT; case InputKey::Home: return LV_KEY_ESC; case InputKey::Next: return LV_KEY_NEXT; case InputKey::Power: return LV_KEY_ESC; case InputKey::Left: return LV_KEY_LEFT; case InputKey::Right: return LV_KEY_RIGHT; case InputKey::Up: return LV_KEY_UP; case InputKey::Down: return LV_KEY_DOWN; case InputKey::F1: return kLvglFunctionKeyF1; case InputKey::Unknown: case InputKey::Fn: case InputKey::Ctrl: case InputKey::Alt: case InputKey::Shift: return 0U; } return 0U; } [[nodiscard]] std::uint8_t expand5(std::uint16_t value) noexcept { return static_cast((value * 255U) / 31U); } [[nodiscard]] std::uint8_t expand6(std::uint16_t value) noexcept { return static_cast((value * 255U) / 63U); } [[nodiscard]] cardputer_zero::core::Color rgb565ToColor( std::uint16_t pixel) noexcept { const auto red = static_cast((pixel >> 11U) & 0x1FU); const auto green = static_cast((pixel >> 5U) & 0x3FU); const auto blue = static_cast(pixel & 0x1FU); return cardputer_zero::core::rgba(expand5(red), expand6(green), expand5(blue)); } [[nodiscard]] lv_color_t color(std::uint32_t hex) noexcept { return lv_color_hex(hex); } void resetBox(lv_obj_t* obj) { lv_obj_remove_style_all(obj); lv_obj_set_style_shadow_width(obj, 0, 0); lv_obj_set_style_border_width(obj, 0, 0); lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); } void applyPanel(lv_obj_t* obj, std::uint32_t bg, std::uint32_t border = embedded_palette::kBorder) { resetBox(obj); lv_obj_set_style_bg_color(obj, color(bg), 0); lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); lv_obj_set_style_border_color(obj, color(border), 0); lv_obj_set_style_border_width(obj, 1, 0); lv_obj_set_style_radius(obj, 6, 0); lv_obj_set_style_pad_all(obj, 14, 0); } void applyTransparent(lv_obj_t* obj) { resetBox(obj); lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); lv_obj_set_style_pad_all(obj, 0, 0); } lv_obj_t* createLabel(lv_obj_t* parent, const char* text, const lv_font_t* font, std::uint32_t text_color, lv_label_long_mode_t long_mode = LV_LABEL_LONG_DOT) { lv_obj_t* label = lv_label_create(parent); lv_obj_set_style_text_color(label, color(text_color), 0); lv_obj_set_style_text_font(label, font, 0); lv_obj_set_style_pad_all(label, 0, 0); lv_label_set_long_mode(label, long_mode); lv_label_set_text(label, text); return label; } void setLabel(lv_obj_t* label, const std::string& text) { if (label != nullptr) { lv_label_set_text(label, text.c_str()); } } void setLabel(lv_obj_t* label, const char* text) { if (label != nullptr) { lv_label_set_text(label, text ? text : ""); } } [[nodiscard]] const char* sectionTitle(Section section) noexcept { switch (section) { case Section::Overview: return "Operational workspace"; case Section::Chat: return "Chat workspace"; case Section::Contacts: return "Contacts & nodes"; case Section::Map: return "Offline map workspace"; case Section::Gps: return "GPS & sky plot"; case Section::Team: return "Team operations"; case Section::Tracker: return "Track recorder"; case Section::RadioTools: return "Radio tools"; case Section::Hardware: return "Hardware status"; case Section::Data: return "Data & offline maps"; case Section::Extensions: return "Extensions"; case Section::Logs: return "Packet and runtime logs"; case Section::Settings: return "Settings workspace"; } return "Operational workspace"; } [[nodiscard]] const char* sectionSubtitle(Section section) noexcept { switch (section) { case Section::Overview: return "Live service snapshot for the Linux handheld target."; case Section::Chat: return "Conversation list and message activity preview."; case Section::Contacts: return "Saved identities, nearby peers, trust and node actions."; case Section::Map: return "Cached tiles, background downloads, contours and field overlays."; case Section::Gps: return "Live receiver health, satellite geometry and fix details."; case Section::Team: return "Shared field state, team chat and location coordination."; case Section::Tracker: return "Persistent GPX, CSV or binary recording on Linux storage."; case Section::RadioTools: return "Energy sweep, SSTV receiver and walkie controls in one workbench."; case Section::Hardware: return "uConsole modules and shared capability status."; case Section::Data: return "Map cache, downloads, tracks and local application storage."; case Section::Extensions: return "Install and remove packages from the Trail Mate catalog."; case Section::Logs: return "Inspect decoded LoRa, GPS and MQTT traffic."; case Section::Settings: return "Grouped configuration entrypoint for Linux targets."; } return ""; } [[nodiscard]] std::string formatCount(std::size_t value) { char buffer[24] = {}; std::snprintf(buffer, sizeof(buffer), "%lu", static_cast(value)); return buffer; } [[nodiscard]] std::string formatCount(int value) { char buffer[24] = {}; std::snprintf(buffer, sizeof(buffer), "%d", value); return buffer; } [[nodiscard]] UConsoleShellOptions validateOptions(UConsoleShellOptions options) { if (options.width <= 0 || options.height <= 0) { throw std::runtime_error("uConsole shell dimensions must be positive."); } if (options.frame_time_ms <= 0) { options.frame_time_ms = 16; } return options; } class UConsoleDesktopShell { public: UConsoleDesktopShell() : services_(), dashboard_model_(services_), chat_model_(services_), map_model_(services_) { } ~UConsoleDesktopShell() { services_.shutdown(); } bool begin() { if (initialized_) return true; if (!services_.initialize()) return false; group_ = lv_group_create(); if (group_ == nullptr) { return false; } lv_group_set_default(group_); buildUi(); initialized_ = true; refreshDashboard(true); return true; } void releaseLvglObjects() noexcept { if (group_ != nullptr) { lv_group_del(group_); group_ = nullptr; } } void tick() { if (!initialized_) return; services_.tick(); refreshMapPreview(false); const auto now = clock::now(); if (active_section_ == Section::Gps && (now - last_gps_preview_refresh_) >= std::chrono::milliseconds(1000)) { last_gps_preview_refresh_ = now; rebuildDesktopPage(); } if ((now - last_refresh_) >= std::chrono::milliseconds(500)) { refreshDashboard(false); } } void enqueueInputs(const std::vector& events) { for (const auto& event : events) { if (handleGlobalShortcut(event)) { continue; } const std::uint32_t mapped = mapInputEvent(event); if (mapped == 0U) continue; key_events_.push_back({mapped, LV_INDEV_STATE_PRESSED}); key_events_.push_back({mapped, LV_INDEV_STATE_RELEASED}); } } [[nodiscard]] lv_group_t* inputGroup() const noexcept { return group_; } bool dequeueKeyEvent(std::uint32_t* key, lv_indev_state_t* state) { if (key_events_.empty()) return false; const QueuedKeyEvent event = key_events_.front(); key_events_.pop_front(); *key = event.key; *state = event.state; return true; } [[nodiscard]] bool hasPendingKeyEvent() const noexcept { return !key_events_.empty(); } private: struct NavBinding { UConsoleDesktopShell* shell = nullptr; Section section = Section::Overview; }; struct ChatConversationBinding { UConsoleDesktopShell* shell = nullptr; std::size_t index = 0; }; static void navEventCb(lv_event_t* event) { const lv_event_code_t code = lv_event_get_code(event); if (code != LV_EVENT_CLICKED && code != LV_EVENT_KEY) { return; } if (code == LV_EVENT_KEY) { const std::uint32_t key = lv_event_get_key(event); if (key != LV_KEY_ENTER && key != LV_KEY_RIGHT) { return; } } auto* binding = static_cast(lv_event_get_user_data(event)); if (binding == nullptr || binding->shell == nullptr) { return; } binding->shell->selectSection(binding->section); } static void chatConversationEventCb(lv_event_t* event) { const lv_event_code_t code = lv_event_get_code(event); if (code != LV_EVENT_CLICKED && code != LV_EVENT_KEY) { return; } if (code == LV_EVENT_KEY) { const std::uint32_t key = lv_event_get_key(event); if (key != LV_KEY_ENTER && key != LV_KEY_RIGHT) { return; } } auto* binding = static_cast(lv_event_get_user_data(event)); if (binding == nullptr || binding->shell == nullptr) { return; } if (binding->shell->chat_model_.selectConversationAt( binding->index, kChatConversationRows)) { binding->shell->refreshDashboard(true); } } static void chatSendEventCb(lv_event_t* event) { const lv_event_code_t code = lv_event_get_code(event); if (code != LV_EVENT_CLICKED && code != LV_EVENT_KEY) { return; } if (code == LV_EVENT_KEY) { const std::uint32_t key = lv_event_get_key(event); if (key != LV_KEY_ENTER) { return; } } auto* shell = static_cast(lv_event_get_user_data(event)); if (shell == nullptr || shell->chat_input_ == nullptr) { return; } const char* text = lv_textarea_get_text(shell->chat_input_); const bool sent = shell->chat_model_.sendText(text == nullptr ? "" : text); if (sent) { lv_textarea_set_text(shell->chat_input_, ""); } shell->refreshDashboard(true); shell->refreshChatWorkspace(true); } bool handleGlobalShortcut(const InputEvent& event) { if (event.key == InputKey::F1) { toggleShortcutOverlay(); return true; } if (event.key == InputKey::Power) { if (shortcuts_visible_) { toggleShortcutOverlay(); return true; } if (active_section_ != Section::Overview) { selectSection(Section::Overview); return true; } return false; } if (event.key != InputKey::Character || event.text == '\0') { return false; } const bool editing_chat = active_section_ == Section::Chat && group_ != nullptr && lv_group_get_focused(group_) == chat_input_; if (editing_chat) { return false; } const char key = static_cast( std::tolower(static_cast(event.text))); if (shortcuts_visible_) { if (key == 'h' || key == '?') { toggleShortcutOverlay(); } return true; } if (active_section_ == Section::Map && handleMapShortcut(key)) { return true; } switch (key) { case 'h': case '?': toggleShortcutOverlay(); return true; case '\\': toggleSidebar(); return true; case '[': cycleSection(-1); return true; case ']': cycleSection(1); return true; case 'o': selectSection(Section::Overview); return true; case 'c': selectSection(Section::Chat); return true; case 'm': selectSection(Section::Map); return true; case 'n': selectSection(Section::Contacts); return true; case 'g': selectSection(Section::Gps); return true; case 't': selectSection(Section::Team); return true; case 'k': selectSection(Section::Tracker); return true; case 'r': selectSection(Section::RadioTools); return true; case 'w': selectSection(Section::Hardware); return true; case 'd': selectSection(Section::Data); return true; case 'e': selectSection(Section::Extensions); return true; case 'l': selectSection(Section::Logs); return true; case 's': selectSection(Section::Settings); return true; default: return false; } } bool handleMapShortcut(char key) { const auto snapshot = map_model_.snapshot(); constexpr int kMapDisplayWidth = 750; constexpr int kMapDisplayHeight = 450; constexpr double kPanStep = 64.0; switch (key) { case 'w': map_model_.panByDisplayDelta(0.0, kPanStep, kMapDisplayWidth, kMapDisplayHeight, snapshot.lat, snapshot.lon, snapshot.zoom, true); break; case 'a': map_model_.panByDisplayDelta(kPanStep, 0.0, kMapDisplayWidth, kMapDisplayHeight, snapshot.lat, snapshot.lon, snapshot.zoom, true); break; case 's': map_model_.panByDisplayDelta(0.0, -kPanStep, kMapDisplayWidth, kMapDisplayHeight, snapshot.lat, snapshot.lon, snapshot.zoom, true); break; case 'd': map_model_.panByDisplayDelta(-kPanStep, 0.0, kMapDisplayWidth, kMapDisplayHeight, snapshot.lat, snapshot.lon, snapshot.zoom, true); break; case 'q': map_model_.zoomOut(); break; case 'e': map_model_.zoomIn(); break; case 'c': map_model_.clearManualCenter(); break; case 'l': if (snapshot.source_label == "OSM") { map_model_.setSource( ::platform::linux_runtime::MapBaseSource::Terrain); } else if (snapshot.source_label == "Terrain") { map_model_.setSource( ::platform::linux_runtime::MapBaseSource::Satellite); } else { map_model_.setSource( ::platform::linux_runtime::MapBaseSource::Osm); } break; case 'o': map_model_.setContourEnabled(!snapshot.contour_enabled); break; case 'm': { const auto active_tool = snapshot.presentation_workspace.active_tool; map_model_.presentationModel().setActiveTool( active_tool == ::ui::map::MapToolKind::MeasureDistance ? ::ui::map::MapToolKind::Pan : ::ui::map::MapToolKind::MeasureDistance); break; } default: return false; } // Apply the map model change synchronously, but defer the expensive // tile/image refresh to the normal frame tick. Refreshing LVGL image // sources here decodes several PNGs on the input loop and makes a // burst of WASD presses look like only every fourth or fifth press // was accepted. refreshFooter(); return true; } void cycleSection(int delta) { const int section_count = static_cast(kNavLabels.size()); const int current = static_cast(active_section_); const int next = (current + delta + section_count) % section_count; selectSection(static_cast
(next)); } void toggleSidebar() { if (sidebar_ == nullptr) { return; } sidebar_collapsed_ = !sidebar_collapsed_; if (sidebar_collapsed_) { lv_obj_add_flag(sidebar_, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_clear_flag(sidebar_, LV_OBJ_FLAG_HIDDEN); const auto index = static_cast(active_section_); if (index < nav_buttons_.size() && nav_buttons_[index] != nullptr) { lv_group_focus_obj(nav_buttons_[index]); } } refreshFooter(); } void toggleShortcutOverlay() { if (shortcut_overlay_ == nullptr) { return; } if (shortcuts_visible_) { shortcuts_visible_ = false; lv_obj_add_flag(shortcut_overlay_, LV_OBJ_FLAG_HIDDEN); return; } lv_obj_del(shortcut_overlay_); shortcut_overlay_ = nullptr; buildShortcutOverlay(lv_screen_active()); shortcuts_visible_ = true; if (shortcut_overlay_ != nullptr) { lv_obj_clear_flag(shortcut_overlay_, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(shortcut_overlay_); } } void selectSection(Section section) { active_section_ = section; refreshNavStyles(); setLabel(workspace_title_, sectionTitle(active_section_)); setLabel(workspace_subtitle_, sectionSubtitle(active_section_)); if (active_section_ != Section::Overview && active_section_ != Section::Chat) { rebuildDesktopPage(); } refreshSectionVisibility(); if (active_section_ == Section::Chat) { refreshChatWorkspace(true); } refreshFooter(); } void buildUi() { lv_obj_t* root = lv_scr_act(); lv_obj_clean(root); resetBox(root); lv_obj_set_style_bg_color(root, color(embedded_palette::kPageBg), 0); lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); lv_obj_set_style_pad_all(root, 0, 0); lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); buildTopBar(root); lv_obj_t* body = lv_obj_create(root); applyTransparent(body); lv_obj_set_width(body, LV_PCT(100)); lv_obj_set_flex_grow(body, 1); lv_obj_set_style_pad_all(body, 10, 0); lv_obj_set_style_pad_column(body, 10, 0); lv_obj_set_flex_flow(body, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); buildSidebar(body); buildWorkspace(body); buildStatusPanel(body); buildBottomBar(root); buildShortcutOverlay(root); selectSection(Section::Overview); } void buildTopBar(lv_obj_t* root) { lv_obj_t* bar = lv_obj_create(root); resetBox(bar); lv_obj_set_width(bar, LV_PCT(100)); lv_obj_set_height(bar, 44); lv_obj_set_style_bg_color(bar, color(embedded_palette::kAccent), 0); lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); lv_obj_set_style_pad_left(bar, 14, 0); lv_obj_set_style_pad_right(bar, 14, 0); lv_obj_set_style_pad_top(bar, 5, 0); lv_obj_set_style_pad_bottom(bar, 5, 0); lv_obj_set_style_pad_column(bar, 8, 0); lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_t* title_wrap = lv_obj_create(bar); applyTransparent(title_wrap); lv_obj_set_height(title_wrap, LV_PCT(100)); lv_obj_set_flex_grow(title_wrap, 1); lv_obj_set_flex_flow(title_wrap, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(title_wrap, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); createLabel(title_wrap, "Trail Mate uConsole", &lv_font_montserrat_16, embedded_palette::kHeaderText); } void buildBottomBar(lv_obj_t* root) { lv_obj_t* bar = lv_obj_create(root); resetBox(bar); lv_obj_set_width(bar, LV_PCT(100)); lv_obj_set_height(bar, 30); lv_obj_set_style_bg_color(bar, color(embedded_palette::kSurfaceAlt), 0); lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); lv_obj_set_style_border_side(bar, LV_BORDER_SIDE_TOP, 0); lv_obj_set_style_border_width(bar, 1, 0); lv_obj_set_style_border_color(bar, color(embedded_palette::kBorder), 0); lv_obj_set_style_pad_left(bar, 12, 0); lv_obj_set_style_pad_right(bar, 12, 0); lv_obj_set_style_pad_column(bar, 10, 0); lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); footer_context_label_ = createLabel(bar, "Overview", &lv_font_montserrat_12, embedded_palette::kTextWarm); lv_obj_set_width(footer_context_label_, 130); footer_status_label_ = createLabel(bar, "Starting services...", &lv_font_montserrat_12, embedded_palette::kTextMuted); lv_obj_set_width(footer_status_label_, 260); lv_obj_set_style_text_align(footer_status_label_, LV_TEXT_ALIGN_CENTER, 0); footer_shortcuts_row_ = lv_obj_create(bar); resetBox(footer_shortcuts_row_); applyTransparent(footer_shortcuts_row_); lv_obj_set_width(footer_shortcuts_row_, 0); lv_obj_set_height(footer_shortcuts_row_, LV_PCT(100)); lv_obj_set_flex_grow(footer_shortcuts_row_, 1); lv_obj_set_style_pad_column(footer_shortcuts_row_, 0, 0); lv_obj_set_flex_flow(footer_shortcuts_row_, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(footer_shortcuts_row_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); } lv_obj_t* createFooterKeycap(lv_obj_t* parent, const char* text, lv_coord_t width) { lv_obj_t* keycap = lv_label_create(parent); lv_obj_set_size(keycap, width, 18); lv_obj_set_style_bg_color(keycap, color(0xF8E6C3), 0); lv_obj_set_style_bg_opa(keycap, LV_OPA_COVER, 0); lv_obj_set_style_border_color(keycap, color(0x8A6E43), 0); lv_obj_set_style_border_width(keycap, 1, 0); lv_obj_set_style_radius(keycap, 3, 0); lv_obj_set_style_text_color(keycap, color(0x25170D), 0); lv_obj_set_style_text_font(keycap, &lv_font_montserrat_10, 0); lv_obj_set_style_text_align(keycap, LV_TEXT_ALIGN_CENTER, 0); lv_label_set_long_mode(keycap, LV_LABEL_LONG_CLIP); lv_label_set_text(keycap, text ? text : ""); return keycap; } void addFooterShortcut(const char* primary, const char* secondary, const char* description) { if (footer_shortcuts_row_ == nullptr) { return; } lv_obj_t* group = lv_obj_create(footer_shortcuts_row_); resetBox(group); applyTransparent(group); lv_obj_set_height(group, 22); lv_obj_set_style_pad_column(group, 0, 0); lv_obj_set_flex_flow(group, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(group, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); if (primary != nullptr && std::strcmp(primary, "WASD") == 0) { for (const char key : std::string("WASD")) { char label[2] = {key, '\0'}; createFooterKeycap(group, label, 16); } } else { const lv_coord_t primary_width = primary != nullptr && std::strlen(primary) > 2 ? 28 : 16; createFooterKeycap(group, primary, primary_width); } if (secondary != nullptr && secondary[0] != '\0') { const lv_coord_t secondary_width = std::strlen(secondary) > 2 ? 28 : 16; createFooterKeycap(group, secondary, secondary_width); } lv_obj_t* label = createLabel(group, description, &lv_font_montserrat_10, embedded_palette::kTextMuted); lv_obj_set_width(label, LV_SIZE_CONTENT); } void refreshFooterShortcuts() { if (footer_shortcuts_row_ == nullptr) { return; } lv_obj_clean(footer_shortcuts_row_); switch (active_section_) { case Section::Chat: addFooterShortcut("\\", nullptr, "Nav"); addFooterShortcut("Tab", nullptr, "Focus"); addFooterShortcut("Enter", nullptr, "Send"); addFooterShortcut("F11", nullptr, "Full"); addFooterShortcut("Ctrl-M", nullptr, "Min"); addFooterShortcut("Ctrl-Q", nullptr, "Quit"); break; case Section::Map: addFooterShortcut("WASD", nullptr, "Pan"); addFooterShortcut("Q", "E", "Zoom"); addFooterShortcut("C", nullptr, "Ctr"); addFooterShortcut("L", nullptr, "Layer"); addFooterShortcut("O", nullptr, "Cnt"); addFooterShortcut("M", nullptr, "Meas"); addFooterShortcut("F1", nullptr, "Help"); break; case Section::Overview: addFooterShortcut("\\", nullptr, "Nav"); addFooterShortcut("C", nullptr, "Chat"); addFooterShortcut("M", nullptr, "Map"); addFooterShortcut("[", "]", "Page"); addFooterShortcut("F1", nullptr, "Help"); break; default: addFooterShortcut("\\", nullptr, "Nav"); addFooterShortcut("Enter", nullptr, "Open"); addFooterShortcut("[", "]", "Page"); addFooterShortcut("F1", nullptr, "Help"); addFooterShortcut("F11", nullptr, "Full"); break; } } void buildShortcutOverlay(lv_obj_t* root) { shortcut_overlay_ = lv_obj_create(root); resetBox(shortcut_overlay_); lv_obj_add_flag(shortcut_overlay_, LV_OBJ_FLAG_FLOATING); lv_obj_set_size(shortcut_overlay_, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(shortcut_overlay_, 0, 0); lv_obj_set_style_bg_color(shortcut_overlay_, color(embedded_palette::kHeaderText), 0); lv_obj_set_style_bg_opa(shortcut_overlay_, LV_OPA_60, 0); lv_obj_t* card = lv_obj_create(shortcut_overlay_); applyPanel(card, embedded_palette::kSurface, embedded_palette::kBorder); lv_obj_set_size(card, 760, 560); lv_obj_set_style_pad_all(card, 10, 0); lv_obj_set_style_pad_row(card, 4, 0); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_center(card); createLabel(card, "uConsole keyboard shortcuts", &lv_font_montserrat_16, embedded_palette::kText); createLabel(card, "Global shortcuts are disabled while typing in the chat " "composer.", &lv_font_montserrat_12, embedded_palette::kTextMuted); auto addKeycap = [](lv_obj_t* parent, const char* text, lv_coord_t width) { lv_obj_t* keycap = lv_label_create(parent); lv_obj_set_size(keycap, width, 18); lv_obj_set_style_bg_color(keycap, lv_color_hex(0xF8E6C3), 0); lv_obj_set_style_bg_opa(keycap, LV_OPA_COVER, 0); lv_obj_set_style_border_width(keycap, 1, 0); lv_obj_set_style_border_color(keycap, lv_color_hex(0x8A6E43), 0); lv_obj_set_style_radius(keycap, 3, 0); lv_obj_set_style_text_font(keycap, &lv_font_montserrat_10, 0); lv_obj_set_style_text_color(keycap, lv_color_hex(0x25170D), 0); lv_obj_set_style_text_align(keycap, LV_TEXT_ALIGN_CENTER, 0); lv_label_set_long_mode(keycap, LV_LABEL_LONG_CLIP); lv_label_set_text(keycap, text ? text : ""); return keycap; }; auto addHelpRow = [this, &addKeycap](lv_obj_t* parent, const char* primary, const char* secondary, const char* description) { lv_obj_t* row = lv_obj_create(parent); resetBox(row); lv_obj_set_width(row, LV_PCT(100)); lv_obj_set_height(row, 22); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(row, 4, 0); lv_obj_t* keys = lv_obj_create(row); resetBox(keys); lv_obj_set_size(keys, 112, 20); lv_obj_set_style_pad_column(keys, 3, 0); lv_obj_set_flex_flow(keys, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(keys, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); if (primary != nullptr && std::strcmp(primary, "WASD") == 0) { for (const char key : std::string("WASD")) { char key_label[2] = {key, '\0'}; addKeycap(keys, key_label, 24); } } else if (secondary != nullptr && secondary[0] != '\0') { addKeycap(keys, primary, 52); addKeycap(keys, secondary, 52); } else { addKeycap(keys, primary, 106); } lv_obj_t* text = lv_label_create(row); lv_obj_set_width(text, 0); lv_obj_set_flex_grow(text, 1); lv_obj_set_style_text_font(text, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(text, lv_color_hex(0x3E2B18), 0); lv_label_set_long_mode(text, LV_LABEL_LONG_DOT); lv_label_set_text(text, description ? description : ""); }; lv_obj_t* columns = lv_obj_create(card); applyTransparent(columns); lv_obj_set_width(columns, LV_PCT(100)); lv_obj_set_flex_grow(columns, 1); lv_obj_set_style_pad_column(columns, 30, 0); lv_obj_set_flex_flow(columns, LV_FLEX_FLOW_ROW); lv_obj_t* navigation = lv_obj_create(columns); applyTransparent(navigation); lv_obj_set_height(navigation, LV_PCT(100)); lv_obj_set_flex_grow(navigation, 1); lv_obj_set_style_pad_row(navigation, 10, 0); lv_obj_set_flex_flow(navigation, LV_FLEX_FLOW_COLUMN); createLabel(navigation, "NAVIGATION", &lv_font_montserrat_12, embedded_palette::kTextDim); addHelpRow(navigation, "Up", "Down", "Move focus"); addHelpRow(navigation, "Tab", nullptr, "Next focus target"); addHelpRow(navigation, "Enter", "Right", "Open or activate"); addHelpRow(navigation, "Esc", nullptr, "Return to overview"); addHelpRow(navigation, "[", "]", "Previous / next workspace"); addHelpRow(navigation, "\\", nullptr, "Collapse navigation"); addHelpRow(navigation, "F1", "H", "Toggle this help"); addHelpRow(navigation, "F11", nullptr, "Toggle fullscreen"); addHelpRow(navigation, "Ctrl-M", nullptr, "Minimize window"); addHelpRow(navigation, "Ctrl-Q", nullptr, "Quit application"); lv_obj_t* workspaces = lv_obj_create(columns); applyTransparent(workspaces); lv_obj_set_height(workspaces, LV_PCT(100)); lv_obj_set_flex_grow(workspaces, 1); lv_obj_set_style_pad_row(workspaces, 10, 0); lv_obj_set_flex_flow(workspaces, LV_FLEX_FLOW_COLUMN); const auto page_index = static_cast(active_section_); createLabel(workspaces, page_index < kNavLabels.size() ? kNavLabels[page_index] : "PAGE", &lv_font_montserrat_12, embedded_palette::kTextDim); switch (active_section_) { case Section::Overview: addHelpRow(workspaces, "O", nullptr, "Open overview"); addHelpRow(workspaces, "C", nullptr, "Open chat"); addHelpRow(workspaces, "M", nullptr, "Open map"); addHelpRow(workspaces, "N", nullptr, "Open contacts"); addHelpRow(workspaces, "G", nullptr, "Open GPS page"); break; case Section::Chat: addHelpRow(workspaces, "Tab", nullptr, "Move focus"); addHelpRow(workspaces, "Enter", nullptr, "Send / activate"); addHelpRow(workspaces, "[", "]", "Previous / next workspace"); break; case Section::Map: addHelpRow(workspaces, "WASD", nullptr, "Pan map"); addHelpRow(workspaces, "Q", "E", "Zoom out / in"); addHelpRow(workspaces, "C", nullptr, "Recenter map"); addHelpRow(workspaces, "L", nullptr, "Cycle base layer"); addHelpRow(workspaces, "O", nullptr, "Toggle contours"); addHelpRow(workspaces, "M", nullptr, "Toggle measure tool"); addHelpRow(workspaces, "[", "]", "Previous / next workspace"); break; default: addHelpRow(workspaces, "Up", "Down", "Move focus"); addHelpRow(workspaces, "Enter", "Right", "Open or activate"); addHelpRow(workspaces, "[", "]", "Previous / next workspace"); break; } createLabel(card, "Virtual keycaps follow the Pager/T-Deck keyboard-first " "interaction style.", &lv_font_montserrat_12, embedded_palette::kTextMuted); lv_obj_add_flag(shortcut_overlay_, LV_OBJ_FLAG_HIDDEN); } void refreshFooter() { const auto index = static_cast(active_section_); std::string context = index < kNavLabels.size() ? kNavLabels[index] : "Workspace"; if (sidebar_collapsed_) { context += " / navigation hidden"; } setLabel(footer_context_label_, context); refreshFooterShortcuts(); } lv_obj_t* createChip(lv_obj_t* parent, const char* text, std::uint32_t bg, std::uint32_t fg) { lv_obj_t* chip = lv_obj_create(parent); resetBox(chip); lv_obj_set_size(chip, LV_SIZE_CONTENT, 26); lv_obj_set_style_bg_color(chip, color(bg), 0); lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); lv_obj_set_style_radius(chip, 6, 0); lv_obj_set_style_pad_left(chip, 8, 0); lv_obj_set_style_pad_right(chip, 8, 0); lv_obj_t* label = createLabel(chip, text, &lv_font_montserrat_12, fg); lv_obj_center(label); return label; } void buildSidebar(lv_obj_t* parent) { sidebar_ = lv_obj_create(parent); applyPanel(sidebar_, embedded_palette::kSurfaceAlt, embedded_palette::kBorder); lv_obj_set_width(sidebar_, 168); lv_obj_set_height(sidebar_, LV_PCT(100)); lv_obj_set_style_pad_all(sidebar_, 7, 0); lv_obj_set_style_pad_row(sidebar_, 2, 0); lv_obj_set_flex_flow(sidebar_, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(sidebar_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); createLabel(sidebar_, "TRAIL MATE", &lv_font_montserrat_16, embedded_palette::kText); for (std::size_t index = 0; index < kNavLabels.size(); ++index) { if (index == 0) { createLabel(sidebar_, "WORKSPACES", &lv_font_montserrat_10, embedded_palette::kTextDim); } else if (index == 3) { createLabel(sidebar_, "FIELD", &lv_font_montserrat_10, embedded_palette::kTextDim); } else if (index == 8) { createLabel(sidebar_, "SYSTEM", &lv_font_montserrat_10, embedded_palette::kTextDim); } nav_bindings_[index] = {this, static_cast
(index)}; nav_buttons_[index] = createNavButton(sidebar_, kNavLabels[index], &nav_bindings_[index]); } } lv_obj_t* createNavButton(lv_obj_t* parent, const char* label_text, NavBinding* binding) { lv_obj_t* button = lv_btn_create(parent); resetBox(button); lv_obj_set_width(button, LV_PCT(100)); lv_obj_set_height(button, 25); lv_obj_set_style_radius(button, 5, 0); lv_obj_set_style_pad_left(button, 8, 0); lv_obj_set_style_pad_right(button, 8, 0); lv_obj_set_style_bg_color(button, color(embedded_palette::kSurface), 0); lv_obj_set_style_bg_opa(button, LV_OPA_COVER, 0); lv_obj_add_event_cb(button, navEventCb, LV_EVENT_CLICKED, binding); lv_obj_add_event_cb(button, navEventCb, LV_EVENT_KEY, binding); lv_group_add_obj(group_, button); lv_obj_t* label = createLabel(button, label_text, &lv_font_montserrat_12, embedded_palette::kText); lv_obj_set_width(label, LV_PCT(100)); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, 0); lv_obj_center(label); return button; } void buildWorkspace(lv_obj_t* parent) { lv_obj_t* workspace = lv_obj_create(parent); applyTransparent(workspace); lv_obj_set_height(workspace, LV_PCT(100)); lv_obj_set_flex_grow(workspace, 1); lv_obj_set_style_pad_row(workspace, 8, 0); lv_obj_set_flex_flow(workspace, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(workspace, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_t* header = lv_obj_create(workspace); applyTransparent(header); lv_obj_set_width(header, LV_PCT(100)); lv_obj_set_height(header, 36); lv_obj_set_style_pad_column(header, 10, 0); lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(header, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); workspace_title_ = createLabel(header, sectionTitle(active_section_), &lv_font_montserrat_20, embedded_palette::kText); workspace_subtitle_ = createLabel(header, sectionSubtitle(active_section_), &lv_font_montserrat_12, embedded_palette::kTextMuted); metrics_panel_ = lv_obj_create(workspace); applyTransparent(metrics_panel_); lv_obj_set_width(metrics_panel_, LV_PCT(100)); lv_obj_set_height(metrics_panel_, 58); lv_obj_set_style_pad_column(metrics_panel_, 8, 0); lv_obj_set_flex_flow(metrics_panel_, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(metrics_panel_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); createMetric(metrics_panel_, 0, "Threads"); createMetric(metrics_panel_, 1, "Unread"); createMetric(metrics_panel_, 2, "Contacts"); createMetric(metrics_panel_, 3, "Nearby"); conversation_panel_ = lv_obj_create(workspace); applyPanel(conversation_panel_, embedded_palette::kSurface); lv_obj_set_width(conversation_panel_, LV_PCT(100)); lv_obj_set_height(conversation_panel_, LV_SIZE_CONTENT); lv_obj_set_style_pad_row(conversation_panel_, 2, 0); lv_obj_set_flex_flow(conversation_panel_, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(conversation_panel_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); createLabel(conversation_panel_, "Recent conversations", &lv_font_montserrat_16, embedded_palette::kText); for (int index = 0; index < kConversationRows; ++index) { buildConversationRow(index); } buildChatWorkspace(workspace); buildDesktopPages(workspace); } void createMetric(lv_obj_t* parent, int index, const char* title) { lv_obj_t* box = lv_obj_create(parent); applyPanel(box, embedded_palette::kSurface); lv_obj_set_height(box, LV_PCT(100)); lv_obj_set_flex_grow(box, 1); lv_obj_set_style_pad_left(box, 10, 0); lv_obj_set_style_pad_right(box, 10, 0); lv_obj_set_flex_flow(box, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(box, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); createLabel(box, title, &lv_font_montserrat_12, embedded_palette::kTextMuted); metric_value_labels_[index] = createLabel(box, "0", &lv_font_montserrat_20, embedded_palette::kText); } void buildConversationRow(int index) { lv_obj_t* row = lv_obj_create(conversation_panel_); applyTransparent(row); lv_obj_set_width(row, LV_PCT(100)); lv_obj_set_height(row, 42); lv_obj_set_style_pad_column(row, 10, 0); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); conversation_rows_[index] = row; conversation_title_labels_[index] = createLabel(row, "-", &lv_font_montserrat_14, embedded_palette::kText, LV_LABEL_LONG_DOT); lv_obj_set_width(conversation_title_labels_[index], 150); conversation_preview_labels_[index] = createLabel(row, "", &lv_font_montserrat_12, embedded_palette::kTextMuted, LV_LABEL_LONG_DOT); lv_obj_set_flex_grow(conversation_preview_labels_[index], 1); conversation_meta_labels_[index] = createLabel(row, "", &lv_font_montserrat_12, embedded_palette::kTextDim, LV_LABEL_LONG_DOT); lv_obj_set_width(conversation_meta_labels_[index], 86); lv_obj_set_style_text_align(conversation_meta_labels_[index], LV_TEXT_ALIGN_RIGHT, 0); } void buildChatWorkspace(lv_obj_t* parent) { chat_panel_ = lv_obj_create(parent); applyPanel(chat_panel_, embedded_palette::kSurface); lv_obj_set_width(chat_panel_, LV_PCT(100)); lv_obj_set_flex_grow(chat_panel_, 1); lv_obj_set_style_pad_column(chat_panel_, 10, 0); lv_obj_set_flex_flow(chat_panel_, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(chat_panel_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_t* conversation_list = lv_obj_create(chat_panel_); applyTransparent(conversation_list); lv_obj_set_width(conversation_list, 280); lv_obj_set_height(conversation_list, LV_PCT(100)); lv_obj_set_style_pad_row(conversation_list, 5, 0); lv_obj_set_flex_flow(conversation_list, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(conversation_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); createLabel(conversation_list, "Conversations", &lv_font_montserrat_16, embedded_palette::kText); for (int index = 0; index < kChatConversationRows; ++index) { buildChatConversationRow(conversation_list, index); } lv_obj_t* thread = lv_obj_create(chat_panel_); applyTransparent(thread); lv_obj_set_height(thread, LV_PCT(100)); lv_obj_set_flex_grow(thread, 1); lv_obj_set_style_pad_row(thread, 6, 0); lv_obj_set_flex_flow(thread, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(thread, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_t* thread_header = lv_obj_create(thread); applyTransparent(thread_header); lv_obj_set_width(thread_header, LV_PCT(100)); lv_obj_set_height(thread_header, 34); lv_obj_set_style_pad_column(thread_header, 8, 0); lv_obj_set_flex_flow(thread_header, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(thread_header, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); chat_title_label_ = createLabel(thread_header, "-", &lv_font_montserrat_16, embedded_palette::kText); chat_meta_label_ = createLabel(thread_header, "-", &lv_font_montserrat_12, embedded_palette::kTextMuted); lv_obj_set_width(chat_title_label_, 190); lv_obj_set_flex_grow(chat_meta_label_, 1); chat_messages_panel_ = lv_obj_create(thread); applyPanel(chat_messages_panel_, embedded_palette::kPanelBg); lv_obj_set_width(chat_messages_panel_, LV_PCT(100)); lv_obj_set_flex_grow(chat_messages_panel_, 1); lv_obj_set_style_pad_row(chat_messages_panel_, 5, 0); lv_obj_set_flex_flow(chat_messages_panel_, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(chat_messages_panel_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); chat_empty_label_ = createLabel( chat_messages_panel_, "No messages yet.", &lv_font_montserrat_14, embedded_palette::kTextMuted); lv_obj_set_width(chat_empty_label_, LV_PCT(100)); for (int index = 0; index < kChatMessageRows; ++index) { buildChatMessageRow(index); } lv_obj_t* input_row = lv_obj_create(thread); applyTransparent(input_row); lv_obj_set_width(input_row, LV_PCT(100)); lv_obj_set_height(input_row, 50); lv_obj_set_style_pad_column(input_row, 8, 0); lv_obj_set_flex_flow(input_row, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(input_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); chat_input_ = lv_textarea_create(input_row); lv_obj_set_height(chat_input_, 40); lv_obj_set_flex_grow(chat_input_, 1); lv_textarea_set_one_line(chat_input_, true); lv_textarea_set_placeholder_text(chat_input_, "Type a message"); lv_obj_set_style_text_font(chat_input_, &lv_font_montserrat_14, 0); lv_obj_set_style_radius(chat_input_, 6, 0); lv_group_add_obj(group_, chat_input_); chat_send_button_ = lv_btn_create(input_row); resetBox(chat_send_button_); lv_obj_set_size(chat_send_button_, 76, 40); lv_obj_set_style_radius(chat_send_button_, 6, 0); lv_obj_set_style_bg_color(chat_send_button_, color(embedded_palette::kAccent), 0); lv_obj_set_style_bg_opa(chat_send_button_, LV_OPA_COVER, 0); lv_obj_add_event_cb(chat_send_button_, chatSendEventCb, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(chat_send_button_, chatSendEventCb, LV_EVENT_KEY, this); lv_group_add_obj(group_, chat_send_button_); lv_obj_t* send_label = createLabel( chat_send_button_, "Send", &lv_font_montserrat_14, embedded_palette::kHeaderText); lv_obj_center(send_label); chat_status_label_ = createLabel(thread, "Ready.", &lv_font_montserrat_12, embedded_palette::kTextMuted); lv_obj_set_width(chat_status_label_, LV_PCT(100)); } lv_obj_t* createPreviewPanel(lv_obj_t* parent, const char* title, int width = 0, std::uint32_t background = embedded_palette::kSurface, bool fill_height = false) { lv_obj_t* panel = lv_obj_create(parent); applyPanel(panel, background); if (width > 0) { lv_obj_set_width(panel, width); } else { lv_obj_set_flex_grow(panel, 1); } lv_obj_set_height(panel, fill_height ? LV_PCT(100) : LV_SIZE_CONTENT); lv_obj_set_style_pad_all(panel, 10, 0); lv_obj_set_style_pad_row(panel, 7, 0); lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); createLabel(panel, title, &lv_font_montserrat_16, embedded_palette::kText); return panel; } lv_obj_t* addPreviewRow(lv_obj_t* parent, const char* title, const char* detail, std::uint32_t accent = embedded_palette::kStatusGreen) { lv_obj_t* row = lv_obj_create(parent); applyPanel(row, embedded_palette::kPanelBg, embedded_palette::kBorder); lv_obj_set_width(row, LV_PCT(100)); lv_obj_set_height(row, 54); lv_obj_set_style_border_side(row, LV_BORDER_SIDE_LEFT, 0); lv_obj_set_style_border_width(row, 4, 0); lv_obj_set_style_border_color(row, color(accent), 0); lv_obj_set_style_pad_all(row, 7, 0); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); createLabel(row, title, &lv_font_montserrat_14, embedded_palette::kText); lv_obj_t* meta = createLabel( row, detail, &lv_font_montserrat_12, embedded_palette::kTextMuted, LV_LABEL_LONG_DOT); lv_obj_set_width(meta, LV_PCT(100)); return meta; } lv_obj_t* addActionPill(lv_obj_t* parent, const char* text, std::uint32_t background = embedded_palette::kSoftGreen, std::uint32_t foreground = embedded_palette::kText) { lv_obj_t* pill = lv_obj_create(parent); resetBox(pill); lv_obj_set_size(pill, LV_SIZE_CONTENT, 28); lv_obj_set_style_bg_color(pill, color(background), 0); lv_obj_set_style_bg_opa(pill, LV_OPA_COVER, 0); lv_obj_set_style_radius(pill, 5, 0); lv_obj_set_style_pad_left(pill, 9, 0); lv_obj_set_style_pad_right(pill, 9, 0); lv_obj_t* label = createLabel(pill, text, &lv_font_montserrat_12, foreground); lv_obj_center(label); return pill; } void buildDesktopPages(lv_obj_t* parent) { desktop_page_panel_ = lv_obj_create(parent); applyTransparent(desktop_page_panel_); lv_obj_set_width(desktop_page_panel_, LV_PCT(100)); lv_obj_set_flex_grow(desktop_page_panel_, 1); lv_obj_set_style_pad_column(desktop_page_panel_, 10, 0); lv_obj_set_flex_flow(desktop_page_panel_, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(desktop_page_panel_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); } static std::string mapSummary(const MapWorkspaceSnapshot& snapshot) { char line[160] = {}; if (snapshot.has_center) { std::snprintf(line, sizeof(line), "%.4f N / %.4f E / zoom %d / %s", snapshot.lat, snapshot.lon, snapshot.zoom, snapshot.source_label.c_str()); } else { std::snprintf(line, sizeof(line), "No map center / %s", snapshot.fix_label.c_str()); } return line; } static std::string mapCacheSummary(const MapWorkspaceSnapshot& snapshot) { char line[128] = {}; std::snprintf(line, sizeof(line), "%llu tiles / %llu KB", static_cast( snapshot.cache_stats.cached_tiles), static_cast( snapshot.cache_stats.total_bytes / 1024U)); return line; } static std::string mapDownloadSummary( const MapWorkspaceSnapshot& snapshot) { std::size_t missing = 0; for (const auto& tile : snapshot.tiles) { if (!tile.available) ++missing; } char line[128] = {}; std::snprintf(line, sizeof(line), "%zu visible / %zu pending", snapshot.tiles.size() - missing, missing); return line; } static std::string mapNodeSummary(const MapWorkspaceSnapshot& snapshot) { char line[128] = {}; std::snprintf(line, sizeof(line), "%zu nodes / %zu via MQTT", snapshot.visible_node_count, snapshot.visible_mqtt_node_count); return line; } void buildMapPreview() { const auto snapshot = map_model_.snapshot(); map_tile_cells_.fill(nullptr); map_tile_images_.fill(nullptr); map_tile_placeholder_labels_.fill(nullptr); map_tile_paths_.fill(std::string{}); lv_obj_t* map = createPreviewPanel( desktop_page_panel_, "Field map", 0, embedded_palette::kMapBg, true); lv_obj_t* toolbar = lv_obj_create(map); applyTransparent(toolbar); lv_obj_set_width(toolbar, LV_PCT(100)); lv_obj_set_height(toolbar, 30); lv_obj_set_style_pad_column(toolbar, 5, 0); lv_obj_set_flex_flow(toolbar, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(toolbar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); addActionPill(toolbar, "Terrain", embedded_palette::kSoftBlue, embedded_palette::kText); addActionPill(toolbar, "Contours"); addActionPill(toolbar, "Recenter"); addActionPill(toolbar, "Measure"); lv_obj_t* grid = lv_obj_create(map); resetBox(grid); lv_obj_set_width(grid, LV_PCT(100)); lv_obj_set_flex_grow(grid, 1); lv_obj_set_style_pad_row(grid, 0, 0); lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); constexpr std::array tile_colors{ embedded_palette::kMapTile1, embedded_palette::kMapTile2, embedded_palette::kMapTile3, embedded_palette::kMapBg, embedded_palette::kMapTile4, embedded_palette::kMapTile5, embedded_palette::kMapTile6, embedded_palette::kMapTile7, embedded_palette::kMapTile8}; const int preview_columns = static_cast(std::max(1U, snapshot.columns)); const int preview_rows = static_cast(std::max(1U, snapshot.rows)); for (int row_index = 0; row_index < preview_rows; ++row_index) { lv_obj_t* row = lv_obj_create(grid); resetBox(row); lv_obj_set_width(row, kMapTileDisplaySize * preview_columns); lv_obj_set_height(row, kMapTileDisplaySize); lv_obj_set_style_pad_column(row, 0, 0); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); for (int column = 0; column < preview_columns; ++column) { const int tile_index = (row_index * preview_columns) + column; lv_obj_t* tile = lv_obj_create(row); resetBox(tile); lv_obj_set_size(tile, kMapTileDisplaySize, kMapTileDisplaySize); lv_obj_set_style_bg_color( tile, color(tile_colors[static_cast(tile_index) % tile_colors.size()]), 0); lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); // Tile cells are a compositing grid, not cards. Keep their // edges transparent so adjacent 256px images share the exact // boundary produced by the common map-tile geometry. lv_obj_set_style_border_width(tile, 0, 0); map_tile_cells_[static_cast(tile_index)] = tile; lv_obj_t* placeholder = createLabel(tile, "", &lv_font_montserrat_10, embedded_palette::kTextDim, LV_LABEL_LONG_WRAP); lv_obj_set_width(placeholder, LV_PCT(100)); lv_obj_set_height(placeholder, LV_PCT(100)); lv_obj_set_style_text_align(placeholder, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_line_space(placeholder, 1, 0); lv_obj_center(placeholder); map_tile_placeholder_labels_ [static_cast(tile_index)] = placeholder; const bool available = tile_index < static_cast(snapshot.tiles.size()) && snapshot.tiles[static_cast(tile_index)] .available; lv_obj_set_style_bg_color( tile, color(available ? tile_colors[static_cast(tile_index) % tile_colors.size()] : embedded_palette::kSurfaceAlt), 0); if (available) { auto* image = lv_image_create(tile); resetBox(image); lv_obj_set_size(image, LV_PCT(100), LV_PCT(100)); map_tile_images_[static_cast(tile_index)] = image; map_tile_paths_[static_cast(tile_index)] = "A:" + snapshot .tiles[static_cast(tile_index)] .path.string(); lv_image_set_inner_align(image, LV_IMAGE_ALIGN_STRETCH); lv_image_set_src( image, map_tile_paths_[static_cast(tile_index)] .c_str()); lv_obj_add_flag(placeholder, LV_OBJ_FLAG_HIDDEN); } else { char xyz[48] = {}; if (tile_index < static_cast(snapshot.tiles.size())) { const auto& id = snapshot.tiles[static_cast(tile_index)] .id; ::ui::map_tiles::formatMapTileCoordinateLabel( static_cast(id.z), static_cast(id.x), static_cast(id.y), xyz, sizeof(xyz)); } lv_label_set_text(placeholder, xyz); map_tile_paths_[static_cast(tile_index)] .clear(); } } } map_meta_label_ = createLabel( map, mapSummary(snapshot).c_str(), &lv_font_montserrat_12, embedded_palette::kTextMuted); lv_obj_t* download = createPreviewPanel( desktop_page_panel_, "Map downloads", 220, embedded_palette::kSurface); map_cache_label_ = addPreviewRow(download, "Cache", mapCacheSummary(snapshot).c_str(), embedded_palette::kStatusGreen); map_download_label_ = addPreviewRow(download, "Visible tiles", mapDownloadSummary(snapshot).c_str(), embedded_palette::kAccentDark); map_retry_label_ = addPreviewRow(download, "Nodes", mapNodeSummary(snapshot).c_str(), embedded_palette::kStatusBlue); addActionPill(download, "Open cache directory"); requestMissingMapTiles(snapshot); } void buildGpsPreview() { std::array live_satellites{}; std::size_t live_satellite_count = 0; platform::ui::gps::GnssStatus live_status{}; const bool have_gnss_snapshot = platform::ui::gps::get_gnss_snapshot( live_satellites.data(), live_satellites.size(), &live_satellite_count, &live_status); const auto gps_state = platform::ui::gps::get_data(); lv_obj_t* sky = createPreviewPanel( desktop_page_panel_, "Satellite sky plot", 0, embedded_palette::kSurface, true); lv_obj_t* stage = lv_obj_create(sky); resetBox(stage); lv_obj_set_width(stage, LV_PCT(100)); lv_obj_set_flex_grow(stage, 1); lv_obj_set_style_bg_color(stage, color(embedded_palette::kMapBg), 0); lv_obj_set_style_bg_opa(stage, LV_OPA_COVER, 0); lv_obj_set_style_border_color(stage, color(embedded_palette::kBorder), 0); lv_obj_set_style_border_width(stage, 1, 0); lv_obj_set_style_radius(stage, 6, 0); constexpr int sky_x = 28; constexpr int sky_y = 38; constexpr int sky_size = 430; constexpr int sky_radius = sky_size / 2; constexpr int center_x = sky_x + sky_radius; constexpr int center_y = sky_y + sky_radius; constexpr std::uint32_t grid_color = 0xC9943F; lv_obj_t* vertical_axis = lv_obj_create(stage); resetBox(vertical_axis); lv_obj_set_pos(vertical_axis, center_x, sky_y); lv_obj_set_size(vertical_axis, 1, sky_size); lv_obj_set_style_bg_color(vertical_axis, color(grid_color), 0); lv_obj_set_style_bg_opa(vertical_axis, LV_OPA_COVER, 0); lv_obj_t* horizontal_axis = lv_obj_create(stage); resetBox(horizontal_axis); lv_obj_set_pos(horizontal_axis, sky_x, center_y); lv_obj_set_size(horizontal_axis, sky_size, 1); lv_obj_set_style_bg_color(horizontal_axis, color(grid_color), 0); lv_obj_set_style_bg_opa(horizontal_axis, LV_OPA_COVER, 0); constexpr std::array ring_sizes{ sky_size, (sky_size * 2) / 3, sky_size / 3}; for (std::size_t index = 0; index < ring_sizes.size(); ++index) { const int ring_size = ring_sizes[index]; lv_obj_t* ring = lv_obj_create(stage); resetBox(ring); lv_obj_set_size(ring, ring_size, ring_size); lv_obj_set_pos(ring, center_x - ring_size / 2, center_y - ring_size / 2); lv_obj_set_style_bg_opa(ring, LV_OPA_TRANSP, 0); lv_obj_set_style_border_color(ring, color(grid_color), 0); lv_obj_set_style_border_width(ring, index == 0 ? 2 : 1, 0); lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0); } lv_obj_t* center_dot = lv_obj_create(stage); resetBox(center_dot); lv_obj_set_size(center_dot, 7, 7); lv_obj_set_pos(center_dot, center_x - 3, center_y - 3); lv_obj_set_style_bg_color( center_dot, color(embedded_palette::kTextWarm), 0); lv_obj_set_style_bg_opa(center_dot, LV_OPA_COVER, 0); lv_obj_set_style_radius(center_dot, LV_RADIUS_CIRCLE, 0); struct PlotLabel { int x; int y; const char* text; const lv_font_t* font; std::uint32_t text_color; }; constexpr std::array plot_labels{{ {center_x - 6, sky_y - 26, "N", &lv_font_montserrat_16, embedded_palette::kText}, {sky_x + sky_size + 11, center_y - 8, "E", &lv_font_montserrat_16, embedded_palette::kText}, {center_x - 5, sky_y + sky_size + 8, "S", &lv_font_montserrat_16, embedded_palette::kText}, {sky_x - 22, center_y - 8, "W", &lv_font_montserrat_16, embedded_palette::kText}, {sky_x + 34, sky_y + 37, "90 deg", &lv_font_montserrat_10, embedded_palette::kTextDim}, {sky_x + 105, sky_y + 108, "60 deg", &lv_font_montserrat_10, embedded_palette::kTextDim}, {center_x + sky_radius / 3 + 8, center_y - 18, "30 deg", &lv_font_montserrat_10, embedded_palette::kTextDim}, {center_x + 9, center_y + 8, "Horizon", &lv_font_montserrat_10, embedded_palette::kTextDim}, }}; for (const auto& item : plot_labels) { lv_obj_t* label = createLabel(stage, item.text, item.font, item.text_color); lv_obj_set_pos(label, item.x, item.y); } struct SatelliteDot { int id; const char* system; float azimuth; float elevation; int snr; bool used; std::uint32_t fill_color; std::uint32_t border_color; std::uint32_t text_color; }; std::array satellites{}; std::size_t satellite_count = 0; const auto system_name = [](gps::GnssSystem system) { switch (system) { case gps::GnssSystem::GPS: return "GPS"; case gps::GnssSystem::GLN: return "GLN"; case gps::GnssSystem::GAL: return "GAL"; case gps::GnssSystem::BD: return "BDS"; case gps::GnssSystem::UNKNOWN: break; } return "UNK"; }; const auto system_color = [](gps::GnssSystem system) { switch (system) { case gps::GnssSystem::GPS: return std::uint32_t{0xE3B11F}; case gps::GnssSystem::GLN: return std::uint32_t{0x2D6FB6}; case gps::GnssSystem::GAL: return std::uint32_t{0x3E7D3E}; case gps::GnssSystem::BD: return std::uint32_t{0xB94A2C}; case gps::GnssSystem::UNKNOWN: break; } return std::uint32_t{0x6E6E6E}; }; for (std::size_t index = 0; index < live_satellite_count && index < satellites.size(); ++index) { const auto& source = live_satellites[index]; const auto fill_color = system_color(source.sys); const auto border_color = source.used ? embedded_palette::kStatusGreen : (source.snr >= 0 && source.snr < 20 ? embedded_palette::kWarn : embedded_palette::kTextDim); satellites[satellite_count++] = { static_cast(source.id), system_name(source.sys), static_cast(source.azimuth), static_cast(source.elevation), static_cast(source.snr), source.used, fill_color, border_color, (source.sys == gps::GnssSystem::BD || source.sys == gps::GnssSystem::GAL ? embedded_palette::kWhite : embedded_palette::kHeaderText)}; } for (std::size_t index = 0; index < satellite_count; ++index) { const auto& sat = satellites[index]; constexpr int dot_size = 31; const float radius = static_cast(sky_radius) * (1.0F - std::clamp(sat.elevation, 0.0F, 90.0F) / 90.0F); const float radians = sat.azimuth * 3.1415926535F / 180.0F; const int satellite_x = static_cast(std::round( static_cast(center_x) + radius * std::sin(radians))); const int satellite_y = static_cast(std::round( static_cast(center_y) - radius * std::cos(radians))); lv_obj_t* dot = lv_obj_create(stage); resetBox(dot); lv_obj_set_size(dot, dot_size, dot_size); lv_obj_set_pos(dot, satellite_x - dot_size / 2, satellite_y - dot_size / 2); lv_obj_set_style_bg_color(dot, color(sat.fill_color), 0); lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); lv_obj_set_style_border_color(dot, color(sat.border_color), 0); lv_obj_set_style_border_width(dot, 3, 0); lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); char id[8]; std::snprintf(id, sizeof(id), "%d", sat.id); lv_obj_t* label = createLabel( dot, id, &lv_font_montserrat_10, sat.text_color); lv_obj_center(label); if (sat.used) { lv_obj_t* use_tag = lv_obj_create(stage); resetBox(use_tag); lv_obj_set_size(use_tag, 28, 16); lv_obj_set_pos(use_tag, satellite_x + 8, satellite_y + 8); lv_obj_set_style_bg_color( use_tag, color(embedded_palette::kStatusGreen), 0); lv_obj_set_style_bg_opa(use_tag, LV_OPA_COVER, 0); lv_obj_set_style_radius(use_tag, 8, 0); lv_obj_t* use_label = createLabel(use_tag, "USE", &lv_font_montserrat_10, embedded_palette::kWhite); lv_obj_center(use_label); } } constexpr int legend_x = 494; lv_obj_t* constellation_title = createLabel(stage, "CONSTELLATION", &lv_font_montserrat_10, embedded_palette::kTextDim); lv_obj_set_pos(constellation_title, legend_x, 76); struct LegendItem { const char* label; std::uint32_t color; }; constexpr std::array systems{{ {"GPS", 0xE3B11F}, {"GLONASS", 0x2D6FB6}, {"Galileo", 0x3E7D3E}, {"BeiDou", 0xB94A2C}, }}; constexpr std::array signals{{ {"Good", 0x3E7D3E}, {"Weak", 0xC18B2C}, {"Not used", 0xB94A2C}, {"In view", 0x6E6E6E}, }}; const auto build_legend = [stage](const std::array& items, int x, int y) { for (std::size_t index = 0; index < items.size(); ++index) { lv_obj_t* swatch = lv_obj_create(stage); resetBox(swatch); lv_obj_set_size(swatch, 13, 13); lv_obj_set_pos(swatch, x, y + static_cast(index) * 28); lv_obj_set_style_bg_color( swatch, color(items[index].color), 0); lv_obj_set_style_bg_opa(swatch, LV_OPA_COVER, 0); lv_obj_set_style_radius(swatch, 4, 0); lv_obj_t* label = createLabel(stage, items[index].label, &lv_font_montserrat_12, embedded_palette::kTextMuted); lv_obj_set_pos(label, x + 21, y - 1 + static_cast(index) * 28); } }; build_legend(systems, legend_x, 96); lv_obj_t* signal_title = createLabel(stage, "SIGNAL BORDER", &lv_font_montserrat_10, embedded_palette::kTextDim); lv_obj_set_pos(signal_title, legend_x, 228); build_legend(signals, legend_x, 250); lv_obj_t* legend_note = createLabel(stage, "Fill = constellation\nRing = signal state", &lv_font_montserrat_10, embedded_palette::kTextDim, LV_LABEL_LONG_WRAP); lv_obj_set_width(legend_note, 170); lv_obj_set_style_text_line_space(legend_note, 3, 0); lv_obj_set_pos(legend_note, legend_x, 376); char sky_meta[96]; if (gps_state.has_alt) { std::snprintf(sky_meta, sizeof(sky_meta), "North up / live 1 Hz / altitude %.1f m", gps_state.alt_m); } else { std::snprintf(sky_meta, sizeof(sky_meta), "North up / live 1 Hz / altitude --"); } createLabel(sky, sky_meta, &lv_font_montserrat_12, embedded_palette::kTextWarm); lv_obj_t* receiver = createPreviewPanel( desktop_page_panel_, "Satellite status", 330, embedded_palette::kSurface, true); char summary[96]; const char* fix_name = "NO FIX"; switch (live_status.fix) { case gps::GnssFix::FIX2D: fix_name = "2D"; break; case gps::GnssFix::FIX3D: fix_name = "3D"; break; case gps::GnssFix::NOFIX: break; } std::snprintf(summary, sizeof(summary), "USE %u/%u HDOP %.1f FIX %s", static_cast(live_status.sats_in_use), static_cast(live_status.sats_in_view), static_cast(live_status.hdop), fix_name); lv_obj_t* receiver_summary = lv_obj_create(receiver); applyPanel(receiver_summary, embedded_palette::kAccent, embedded_palette::kAccentDark); lv_obj_set_width(receiver_summary, LV_PCT(100)); lv_obj_set_height(receiver_summary, 42); lv_obj_set_style_pad_all(receiver_summary, 8, 0); lv_obj_t* summary_label = createLabel(receiver_summary, summary, &lv_font_montserrat_12, embedded_palette::kHeaderText); lv_obj_center(summary_label); lv_obj_t* table_header = lv_obj_create(receiver); resetBox(table_header); lv_obj_set_width(table_header, LV_PCT(100)); lv_obj_set_height(table_header, 28); lv_obj_set_style_bg_color( table_header, color(embedded_palette::kSurfaceAlt), 0); lv_obj_set_style_bg_opa(table_header, LV_OPA_COVER, 0); constexpr std::array column_x{8, 45, 112, 176, 227}; constexpr std::array column_names{ "ID", "SYS", "ELEV", "SNR", "USE"}; for (std::size_t column = 0; column < column_names.size(); ++column) { lv_obj_t* label = createLabel(table_header, column_names[column], &lv_font_montserrat_10, embedded_palette::kTextDim); lv_obj_set_pos(label, column_x[column], 7); } for (std::size_t row_index = 0; row_index < satellite_count; ++row_index) { const auto& sat = satellites[row_index]; lv_obj_t* row = lv_obj_create(receiver); resetBox(row); lv_obj_set_width(row, LV_PCT(100)); lv_obj_set_height(row, 37); lv_obj_set_style_bg_color( row, color(row_index % 2 == 0 ? embedded_palette::kPanelBg : embedded_palette::kSurface), 0); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); lv_obj_set_style_border_color( row, color(embedded_palette::kSeparator), 0); lv_obj_set_style_border_width(row, 1, 0); lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); char id[8]; char elevation[12]; char snr[8]; std::snprintf(id, sizeof(id), "%d", sat.id); std::snprintf(elevation, sizeof(elevation), "%.0f deg", static_cast(sat.elevation)); std::snprintf(snr, sizeof(snr), "%d", sat.snr); const std::array values{ id, sat.system, elevation, snr, sat.used ? "YES" : "NO"}; for (std::size_t column = 0; column < values.size(); ++column) { const std::uint32_t value_color = column == 1 ? sat.fill_color : (column == 4 ? (sat.used ? embedded_palette::kStatusGreen : embedded_palette::kWarn) : embedded_palette::kText); lv_obj_t* label = createLabel(row, values[column], &lv_font_montserrat_12, value_color); lv_obj_set_pos(label, column_x[column], 10); } } if (satellite_count == 0) { const char* waiting_text = have_gnss_snapshot ? "Waiting for satellites / AIO2 GPS" : "GPS runtime unavailable"; lv_obj_t* waiting = createLabel(receiver, waiting_text, &lv_font_montserrat_12, embedded_palette::kTextWarm); lv_obj_set_width(waiting, LV_PCT(100)); lv_obj_set_style_text_align(waiting, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_y(waiting, 42); } char receiver_meta_text[128]; if (gps_state.valid) { std::snprintf(receiver_meta_text, sizeof(receiver_meta_text), "Live / %.5f, %.5f / GPS UTC", gps_state.lat, gps_state.lng); } else { std::snprintf(receiver_meta_text, sizeof(receiver_meta_text), "Live / no fix / AIO2 GPS"); } lv_obj_t* receiver_meta = createLabel(receiver, receiver_meta_text, &lv_font_montserrat_10, embedded_palette::kTextMuted); lv_obj_set_width(receiver_meta, LV_PCT(100)); addActionPill(receiver, "Open map"); } void requestMissingMapTiles(const MapWorkspaceSnapshot& snapshot) { if (map_download_jobs_.size() >= 6U) return; for (const auto& tile : snapshot.tiles) { if (tile.available || map_download_jobs_.size() >= 6U) continue; map_download_jobs_.push_back(std::async( std::launch::async, [this, id = tile.id]() { return map_model_.ensureTile(id); })); } } void refreshMapPreview(bool force) { for (auto it = map_download_jobs_.begin(); it != map_download_jobs_.end();) { if (it->wait_for(std::chrono::milliseconds(0)) == std::future_status::ready) { it = map_download_jobs_.erase(it); } else { ++it; } } if (map_meta_label_ == nullptr) return; if (!force && active_section_ != Section::Map) return; const auto now = clock::now(); if (!force && (now - last_map_preview_refresh_) < std::chrono::milliseconds(100)) { return; } last_map_preview_refresh_ = now; const auto snapshot = map_model_.snapshot(); setLabel(map_meta_label_, mapSummary(snapshot)); setLabel(map_cache_label_, mapCacheSummary(snapshot)); setLabel(map_download_label_, mapDownloadSummary(snapshot)); setLabel(map_retry_label_, mapNodeSummary(snapshot)); for (std::size_t index = 0; index < map_tile_cells_.size() && index < snapshot.tiles.size(); ++index) { auto* tile = map_tile_cells_[index]; if (tile == nullptr) continue; if (snapshot.tiles[index].available) { if (map_tile_placeholder_labels_[index] != nullptr) { lv_obj_add_flag(map_tile_placeholder_labels_[index], LV_OBJ_FLAG_HIDDEN); } if (map_tile_images_[index] == nullptr) { auto* image = lv_image_create(tile); resetBox(image); lv_obj_set_size(image, LV_PCT(100), LV_PCT(100)); map_tile_images_[index] = image; } const std::string path = "A:" + snapshot.tiles[index].path.string(); if (map_tile_paths_[index] != path) { map_tile_paths_[index] = path; lv_image_set_inner_align(map_tile_images_[index], LV_IMAGE_ALIGN_STRETCH); lv_image_set_src(map_tile_images_[index], map_tile_paths_[index].c_str()); } lv_obj_set_style_bg_color( tile, color(embedded_palette::kMapTile2), 0); } else { if (map_tile_images_[index] != nullptr) { lv_obj_del(map_tile_images_[index]); map_tile_images_[index] = nullptr; } if (map_tile_placeholder_labels_[index] != nullptr) { char xyz[48] = {}; const auto& id = snapshot.tiles[index].id; ::ui::map_tiles::formatMapTileCoordinateLabel( static_cast(id.z), static_cast(id.x), static_cast(id.y), xyz, sizeof(xyz)); lv_label_set_text(map_tile_placeholder_labels_[index], xyz); lv_obj_clear_flag(map_tile_placeholder_labels_[index], LV_OBJ_FLAG_HIDDEN); } lv_obj_set_style_bg_color( tile, color(embedded_palette::kSurfaceAlt), 0); } } if (map_download_jobs_.empty()) { requestMissingMapTiles(snapshot); } } void buildRadioToolsPreview() { lv_obj_t* spectrum = createPreviewPanel( desktop_page_panel_, "Energy sweep", 0, embedded_palette::kSurface); lv_obj_t* chart = lv_obj_create(spectrum); applyPanel(chart, embedded_palette::kPlotBg, embedded_palette::kBorder); lv_obj_set_width(chart, LV_PCT(100)); lv_obj_set_height(chart, 280); lv_obj_set_style_pad_all(chart, 12, 0); lv_obj_set_style_pad_column(chart, 3, 0); lv_obj_set_flex_flow(chart, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(chart, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); constexpr std::array levels{ 42, 48, 44, 55, 50, 62, 71, 95, 128, 176, 112, 76, 59, 52, 48, 66, 88, 142, 196, 154, 92, 68, 58, 49, 45, 54, 73, 98}; for (std::size_t index = 0; index < levels.size(); ++index) { lv_obj_t* bar = lv_obj_create(chart); resetBox(bar); lv_obj_set_width(bar, 0); lv_obj_set_height(bar, levels[index]); lv_obj_set_flex_grow(bar, 1); lv_obj_set_style_bg_color( bar, color(levels[index] > 150 ? embedded_palette::kAccent : embedded_palette::kStatusBlue), 0); lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); lv_obj_set_style_radius(bar, 2, 0); } createLabel(spectrum, "433.050 - 434.790 MHz / strongest 434.225 MHz at -69.1 dBm", &lv_font_montserrat_12, embedded_palette::kTextWarm); addActionPill(spectrum, "Run sweep"); lv_obj_t* sessions = lv_obj_create(desktop_page_panel_); applyTransparent(sessions); lv_obj_set_width(sessions, 330); lv_obj_set_height(sessions, LV_PCT(100)); lv_obj_set_style_pad_row(sessions, 10, 0); lv_obj_set_flex_flow(sessions, LV_FLEX_FLOW_COLUMN); lv_obj_t* sstv = createPreviewPanel( sessions, "SSTV receiver", 330, embedded_palette::kSurface); addPreviewRow(sstv, "Mode", "Martin M1 / waiting for sync", embedded_palette::kStatusBlue); addPreviewRow(sstv, "Last image", "No decoded image yet"); addActionPill(sstv, "Start receiver"); lv_obj_t* walkie = createPreviewPanel( sessions, "Walkie", 330, embedded_palette::kSurface); addPreviewRow(walkie, "Session", "Stopped / capability: Simulated", embedded_palette::kWarn); addPreviewRow(walkie, "Channel", "433.175 MHz / monitor off"); lv_obj_t* actions = lv_obj_create(walkie); applyTransparent(actions); lv_obj_set_width(actions, LV_PCT(100)); lv_obj_set_height(actions, 30); lv_obj_set_style_pad_column(actions, 6, 0); lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_ROW); addActionPill(actions, "Start session"); addActionPill(actions, "Press PTT", embedded_palette::kSoftBlue, embedded_palette::kText); } void buildExtensionsPreview() { lv_obj_t* catalog = createPreviewPanel( desktop_page_panel_, "Available packages", 0, embedded_palette::kSurface); addPreviewRow(catalog, "English language pack", "v1.4 / locale / 86 KB / installed", embedded_palette::kStatusGreen); addPreviewRow(catalog, "Noto Sans CJK field font", "v2.1 / font / 4.8 MB / update available", embedded_palette::kAccentDark); addPreviewRow(catalog, "Pinyin input method", "v1.2 / IME / 612 KB / available", embedded_palette::kStatusBlue); addPreviewRow(catalog, "Emergency symbol set", "v1.0 / assets / 138 KB / available", embedded_palette::kTextWarm); addPreviewRow(catalog, "Topographic map style", "v0.9 / map style / 244 KB / available", embedded_palette::kStatusGreen); lv_obj_t* details = createPreviewPanel( desktop_page_panel_, "Package details", 320, embedded_palette::kSurface); createLabel(details, "Noto Sans CJK field font", &lv_font_montserrat_16, embedded_palette::kText); createLabel(details, "Typography for multilingual field messages,", &lv_font_montserrat_12, embedded_palette::kTextMuted); createLabel(details, "maps, node names and team coordination.", &lv_font_montserrat_12, embedded_palette::kTextMuted); addPreviewRow(details, "Compatibility", "Desktop / full memory profile", embedded_palette::kStatusGreen); addPreviewRow(details, "Storage", "linux-local / 4.8 MB"); addPreviewRow(details, "Status", "Update available", embedded_palette::kAccentDark); addActionPill(details, "Install update"); } void buildGenericFieldPreview(Section section) { lv_obj_t* primary = createPreviewPanel( desktop_page_panel_, sectionTitle(section), 0, embedded_palette::kSurface); lv_obj_t* secondary = createPreviewPanel( desktop_page_panel_, "Desktop actions", 320, embedded_palette::kSurface); switch (section) { case Section::Contacts: addPreviewRow(primary, "Base Camp", "!435a1002 / Meshtastic / online", embedded_palette::kStatusGreen); addPreviewRow(primary, "Trail Scout", "!435a1003 / Meshtastic / 2 min ago", embedded_palette::kStatusBlue); addPreviewRow(primary, "MQTT Relay", "!435a1010 / MQTT / trusted", embedded_palette::kTextWarm); addPreviewRow(secondary, "Selected node", "Base Camp / key verified"); addActionPill(secondary, "Open chat"); addActionPill(secondary, "Request NodeInfo"); addActionPill(secondary, "Ignore node", embedded_palette::kSoftWarn, embedded_palette::kWarn); break; case Section::Team: addPreviewRow(primary, "Alpha team", "4 members / leader Base Camp", embedded_palette::kTextWarm); addPreviewRow(primary, "Trail Scout shared position", "31.2308, 121.4741 / 2 min ago", embedded_palette::kStatusBlue); addPreviewRow(primary, "Waypoint updated", "North checkpoint / synced", embedded_palette::kStatusGreen); addActionPill(secondary, "Open team chat"); addActionPill(secondary, "Share current position"); break; case Section::Tracker: addPreviewRow(primary, "Recorder", "Recording / GPX", embedded_palette::kWarn); addPreviewRow(primary, "Current file", "track-20260723-142830.gpx", embedded_palette::kStatusBlue); addPreviewRow(primary, "Sampling", "10 s / GPS 3D fix"); addActionPill(secondary, "Stop recording", embedded_palette::kSoftWarn, embedded_palette::kWarn); addActionPill(secondary, "Open track directory"); break; case Section::Hardware: addPreviewRow(primary, "AIO2", "Available / USB serial", embedded_palette::kStatusGreen); addPreviewRow(primary, "LoRa SX1262", "Available / spidev1.0", embedded_palette::kStatusGreen); addPreviewRow(primary, "GPS", "Available / 3D fix", embedded_palette::kStatusGreen); addPreviewRow(primary, "Audio", "Simulated backend", embedded_palette::kWarn); addPreviewRow(secondary, "Runtime", "Linux desktop / uConsole"); addPreviewRow(secondary, "Protocol", "Native Meshtastic"); break; case Section::Data: addPreviewRow(primary, "Map cache", "2,418 tiles / 386 MB / healthy", embedded_palette::kStatusGreen); addPreviewRow(primary, "Background downloads", "4 active / 2 retrying", embedded_palette::kAccentDark); addPreviewRow(primary, "Track files", "18 files / 42 MB"); addPreviewRow(primary, "Messages", "SQLite / 3,284 records"); addActionPill(secondary, "Open map workspace"); addActionPill(secondary, "Retry failed tiles"); break; case Section::Logs: addPreviewRow(primary, "14:28:31 LoRa RX", "Meshtastic text / !435a1002 / RSSI -81", embedded_palette::kStatusGreen); addPreviewRow(primary, "14:28:30 GPS", "GGA / 3D fix / 8 satellites", embedded_palette::kStatusBlue); addPreviewRow(primary, "14:28:28 MQTT RX", "msh/CN/2/e/LongFast / decoded", embedded_palette::kTextWarm); addPreviewRow(secondary, "Source filter", "LoRa"); addActionPill(secondary, "GPS"); addActionPill(secondary, "LoRa"); addActionPill(secondary, "MQTT"); break; case Section::Settings: addPreviewRow(primary, "Radio protocol", "Meshtastic"); addPreviewRow(primary, "Region and preset", "CN / LongFast / 433.175 MHz"); addPreviewRow(primary, "GPS", "Enabled / 5 s interval"); addPreviewRow(primary, "Map", "Terrain / zoom 14 / auto download"); addPreviewRow(secondary, "Configuration", "Unsaved changes: 0"); addActionPill(secondary, "Apply settings"); addActionPill(secondary, "Reload"); break; default: break; } } void rebuildDesktopPage() { if (desktop_page_panel_ == nullptr) { return; } lv_obj_clean(desktop_page_panel_); switch (active_section_) { case Section::Map: buildMapPreview(); break; case Section::Gps: buildGpsPreview(); break; case Section::RadioTools: buildRadioToolsPreview(); break; case Section::Extensions: buildExtensionsPreview(); break; case Section::Contacts: case Section::Team: case Section::Tracker: case Section::Hardware: case Section::Data: case Section::Logs: case Section::Settings: buildGenericFieldPreview(active_section_); break; case Section::Overview: case Section::Chat: break; } } void buildChatConversationRow(lv_obj_t* parent, int index) { chat_conversation_bindings_[index] = {this, static_cast(index)}; lv_obj_t* button = lv_btn_create(parent); resetBox(button); lv_obj_set_width(button, LV_PCT(100)); lv_obj_set_height(button, 74); lv_obj_set_style_radius(button, 6, 0); lv_obj_set_style_pad_all(button, 8, 0); lv_obj_set_style_bg_color(button, color(embedded_palette::kPanelBg), 0); lv_obj_set_style_bg_opa(button, LV_OPA_COVER, 0); lv_obj_set_flex_flow(button, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(button, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_add_event_cb(button, chatConversationEventCb, LV_EVENT_CLICKED, &chat_conversation_bindings_[index]); lv_obj_add_event_cb(button, chatConversationEventCb, LV_EVENT_KEY, &chat_conversation_bindings_[index]); lv_group_add_obj(group_, button); chat_conversation_buttons_[index] = button; chat_conversation_title_labels_[index] = createLabel(button, "-", &lv_font_montserrat_14, embedded_palette::kText); chat_conversation_preview_labels_[index] = createLabel(button, "", &lv_font_montserrat_12, embedded_palette::kTextMuted); chat_conversation_meta_labels_[index] = createLabel(button, "", &lv_font_montserrat_12, embedded_palette::kTextDim); lv_obj_set_width(chat_conversation_title_labels_[index], LV_PCT(100)); lv_obj_set_width(chat_conversation_preview_labels_[index], LV_PCT(100)); lv_obj_set_width(chat_conversation_meta_labels_[index], LV_PCT(100)); } void buildChatMessageRow(int index) { lv_obj_t* row = lv_obj_create(chat_messages_panel_); applyPanel(row, embedded_palette::kSurface); lv_obj_set_width(row, LV_PCT(100)); lv_obj_set_height(row, 72); lv_obj_set_style_pad_all(row, 8, 0); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); chat_message_rows_[index] = row; chat_message_sender_labels_[index] = createLabel(row, "-", &lv_font_montserrat_12, embedded_palette::kTextWarm); chat_message_text_labels_[index] = createLabel(row, "", &lv_font_montserrat_14, embedded_palette::kText, LV_LABEL_LONG_WRAP); chat_message_meta_labels_[index] = createLabel(row, "", &lv_font_montserrat_12, embedded_palette::kTextMuted); lv_obj_set_width(chat_message_sender_labels_[index], LV_PCT(100)); lv_obj_set_width(chat_message_text_labels_[index], LV_PCT(100)); lv_obj_set_width(chat_message_meta_labels_[index], LV_PCT(100)); } void buildStatusPanel(lv_obj_t* parent) { status_panel_ = lv_obj_create(parent); applyPanel(status_panel_, embedded_palette::kSurface); lv_obj_set_width(status_panel_, 330); lv_obj_set_height(status_panel_, LV_PCT(100)); lv_obj_set_style_pad_row(status_panel_, 12, 0); lv_obj_set_flex_flow(status_panel_, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(status_panel_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); createLabel(status_panel_, "Runtime and capabilities", &lv_font_montserrat_16, embedded_palette::kText); for (int index = 0; index < kCapabilityRows; ++index) { capability_labels_[index] = createLabel(status_panel_, "-", &lv_font_montserrat_12, embedded_palette::kTextMuted, LV_LABEL_LONG_WRAP); lv_obj_set_width(capability_labels_[index], LV_PCT(100)); } lv_obj_t* divider = lv_obj_create(status_panel_); resetBox(divider); lv_obj_set_width(divider, LV_PCT(100)); lv_obj_set_height(divider, 1); lv_obj_set_style_bg_color(divider, color(embedded_palette::kSeparator), 0); lv_obj_set_style_bg_opa(divider, LV_OPA_COVER, 0); createLabel(status_panel_, "Contacts and nearby nodes", &lv_font_montserrat_16, embedded_palette::kText); for (int index = 0; index < kContactRows; ++index) { buildContactRow(status_panel_, index); } } void buildContactRow(lv_obj_t* parent, int index) { lv_obj_t* row = lv_obj_create(parent); applyTransparent(row); lv_obj_set_width(row, LV_PCT(100)); lv_obj_set_height(row, 48); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); contact_rows_[index] = row; contact_name_labels_[index] = createLabel(row, "-", &lv_font_montserrat_14, embedded_palette::kText); lv_obj_set_width(contact_name_labels_[index], LV_PCT(100)); contact_meta_labels_[index] = createLabel(row, "", &lv_font_montserrat_12, embedded_palette::kTextMuted); lv_obj_set_width(contact_meta_labels_[index], LV_PCT(100)); } void refreshNavStyles() { const auto active_index = static_cast(active_section_); for (std::size_t index = 0; index < nav_buttons_.size(); ++index) { lv_obj_t* button = nav_buttons_[index]; if (button == nullptr) continue; const bool active = index == active_index; lv_obj_set_style_bg_color( button, color(active ? embedded_palette::kAccent : embedded_palette::kSurface), 0); lv_obj_set_style_border_width(button, active ? 1 : 0, 0); lv_obj_set_style_border_color( button, color(embedded_palette::kBorder), 0); lv_obj_t* label = lv_obj_get_child(button, 0); if (label != nullptr) { lv_obj_set_style_text_color( label, color(active ? embedded_palette::kHeaderText : embedded_palette::kText), 0); } } } void refreshSectionVisibility() { const bool overview_active = active_section_ == Section::Overview; const bool chat_active = active_section_ == Section::Chat; const bool desktop_page_active = !overview_active && !chat_active; if (metrics_panel_ != nullptr) { if (!overview_active) lv_obj_add_flag(metrics_panel_, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(metrics_panel_, LV_OBJ_FLAG_HIDDEN); } if (conversation_panel_ != nullptr) { if (!overview_active) lv_obj_add_flag(conversation_panel_, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(conversation_panel_, LV_OBJ_FLAG_HIDDEN); } if (chat_panel_ != nullptr) { if (chat_active) lv_obj_clear_flag(chat_panel_, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(chat_panel_, LV_OBJ_FLAG_HIDDEN); } if (desktop_page_panel_ != nullptr) { if (desktop_page_active) lv_obj_clear_flag(desktop_page_panel_, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(desktop_page_panel_, LV_OBJ_FLAG_HIDDEN); } if (status_panel_ != nullptr) { lv_obj_add_flag(status_panel_, LV_OBJ_FLAG_HIDDEN); } } void refreshDashboard(bool force) { const auto now = clock::now(); if (!force && (now - last_refresh_) < std::chrono::milliseconds(500)) { return; } last_refresh_ = now; const UConsoleDashboardSnapshot snapshot = dashboard_model_.snapshot(); setLabel(top_mesh_label_, "Mesh: " + snapshot.mesh_protocol); setLabel(top_node_label_, "Node: " + snapshot.self_node); setLabel(top_unread_label_, "Unread: " + formatCount(snapshot.unread_count)); setLabel(footer_status_label_, snapshot.mesh_protocol + " / node " + snapshot.self_node + " / " + formatCount(snapshot.unread_count) + " unread"); setLabel(metric_value_labels_[0], formatCount(snapshot.conversation_count)); setLabel(metric_value_labels_[1], formatCount(snapshot.unread_count)); setLabel(metric_value_labels_[2], formatCount(snapshot.contact_count)); setLabel(metric_value_labels_[3], formatCount(snapshot.nearby_count)); for (int index = 0; index < kConversationRows; ++index) { const bool visible = index < static_cast(snapshot.conversations.size()); if (visible) { lv_obj_clear_flag(conversation_rows_[index], LV_OBJ_FLAG_HIDDEN); const auto& item = snapshot.conversations[index]; setLabel(conversation_title_labels_[index], item.title); setLabel(conversation_preview_labels_[index], item.preview); setLabel(conversation_meta_labels_[index], item.meta); lv_obj_set_style_text_color( conversation_meta_labels_[index], color(item.unread > 0 ? embedded_palette::kAccentDark : embedded_palette::kTextDim), 0); } else { lv_obj_add_flag(conversation_rows_[index], LV_OBJ_FLAG_HIDDEN); } } for (int index = 0; index < kCapabilityRows; ++index) { if (index < static_cast(snapshot.capability_lines.size())) { setLabel(capability_labels_[index], snapshot.capability_lines[index]); } else { setLabel(capability_labels_[index], ""); } } for (int index = 0; index < kContactRows; ++index) { const bool visible = index < static_cast(snapshot.contacts.size()); if (visible) { lv_obj_clear_flag(contact_rows_[index], LV_OBJ_FLAG_HIDDEN); const auto& contact = snapshot.contacts[index]; setLabel(contact_name_labels_[index], contact.name); setLabel(contact_meta_labels_[index], contact.node_id + " / " + contact.protocol + " / " + contact.status); } else { lv_obj_add_flag(contact_rows_[index], LV_OBJ_FLAG_HIDDEN); } } refreshChatWorkspace(force); } void refreshChatWorkspace(bool force) { if (chat_panel_ == nullptr) return; if (!force && active_section_ != Section::Chat) return; const ChatWorkspaceSnapshot snapshot = chat_model_.snapshot(kChatConversationRows, kChatMessageRows); setLabel(chat_title_label_, snapshot.active_title); setLabel(chat_meta_label_, snapshot.active_meta); setLabel(chat_status_label_, snapshot.action_status.empty() ? "Ready." : snapshot.action_status); for (int index = 0; index < kChatConversationRows; ++index) { const bool visible = index < static_cast(snapshot.conversations.size()); if (!visible) { lv_obj_add_flag(chat_conversation_buttons_[index], LV_OBJ_FLAG_HIDDEN); continue; } lv_obj_clear_flag(chat_conversation_buttons_[index], LV_OBJ_FLAG_HIDDEN); const auto& item = snapshot.conversations[index]; setLabel(chat_conversation_title_labels_[index], item.title); setLabel(chat_conversation_preview_labels_[index], item.preview); setLabel(chat_conversation_meta_labels_[index], item.meta); lv_obj_set_style_bg_color( chat_conversation_buttons_[index], color(item.active ? embedded_palette::kSoftAmber : embedded_palette::kPanelBg), 0); lv_obj_set_style_border_width(chat_conversation_buttons_[index], item.active ? 1 : 0, 0); lv_obj_set_style_border_color( chat_conversation_buttons_[index], color(embedded_palette::kBorder), 0); lv_obj_set_style_text_color( chat_conversation_meta_labels_[index], color(item.unread > 0 ? embedded_palette::kAccentDark : embedded_palette::kTextMuted), 0); } const bool has_messages = !snapshot.messages.empty(); if (chat_empty_label_ != nullptr) { if (has_messages) lv_obj_add_flag(chat_empty_label_, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(chat_empty_label_, LV_OBJ_FLAG_HIDDEN); } for (int index = 0; index < kChatMessageRows; ++index) { const bool visible = index < static_cast(snapshot.messages.size()); if (!visible) { lv_obj_add_flag(chat_message_rows_[index], LV_OBJ_FLAG_HIDDEN); continue; } lv_obj_clear_flag(chat_message_rows_[index], LV_OBJ_FLAG_HIDDEN); const auto& item = snapshot.messages[index]; setLabel(chat_message_sender_labels_[index], item.sender); setLabel(chat_message_text_labels_[index], item.text); setLabel(chat_message_meta_labels_[index], item.meta); lv_obj_set_style_bg_color( chat_message_rows_[index], color(item.failed ? embedded_palette::kSoftWarn : (item.outgoing ? embedded_palette::kSoftGreen : embedded_palette::kSurface)), 0); lv_obj_set_style_text_color( chat_message_meta_labels_[index], color(item.failed ? embedded_palette::kError : embedded_palette::kTextMuted), 0); } } linux_app::LinuxAppServices services_; UConsoleDashboardModel dashboard_model_; UConsoleChatWorkspaceModel chat_model_; UConsoleMapWorkspaceModel map_model_; bool initialized_ = false; bool sidebar_collapsed_ = false; bool shortcuts_visible_ = false; Section active_section_ = Section::Overview; lv_group_t* group_ = nullptr; std::deque key_events_{}; clock::time_point last_refresh_{}; lv_obj_t* sidebar_ = nullptr; lv_obj_t* workspace_title_ = nullptr; lv_obj_t* workspace_subtitle_ = nullptr; lv_obj_t* top_mesh_label_ = nullptr; lv_obj_t* top_node_label_ = nullptr; lv_obj_t* top_unread_label_ = nullptr; lv_obj_t* footer_context_label_ = nullptr; lv_obj_t* footer_status_label_ = nullptr; lv_obj_t* footer_shortcuts_row_ = nullptr; lv_obj_t* shortcut_overlay_ = nullptr; lv_obj_t* metrics_panel_ = nullptr; lv_obj_t* conversation_panel_ = nullptr; lv_obj_t* chat_panel_ = nullptr; lv_obj_t* desktop_page_panel_ = nullptr; lv_obj_t* status_panel_ = nullptr; lv_obj_t* chat_messages_panel_ = nullptr; lv_obj_t* chat_title_label_ = nullptr; lv_obj_t* chat_meta_label_ = nullptr; lv_obj_t* chat_empty_label_ = nullptr; lv_obj_t* chat_input_ = nullptr; lv_obj_t* chat_send_button_ = nullptr; lv_obj_t* chat_status_label_ = nullptr; lv_obj_t* map_meta_label_ = nullptr; lv_obj_t* map_cache_label_ = nullptr; lv_obj_t* map_download_label_ = nullptr; lv_obj_t* map_retry_label_ = nullptr; std::array map_tile_cells_{}; std::array map_tile_images_{}; std::array map_tile_placeholder_labels_{}; std::array map_tile_paths_{}; std::vector> map_download_jobs_{}; clock::time_point last_map_preview_refresh_{}; clock::time_point last_gps_preview_refresh_{}; std::array nav_bindings_{}; std::array nav_buttons_{}; std::array metric_value_labels_{}; std::array conversation_rows_{}; std::array conversation_title_labels_{}; std::array conversation_preview_labels_{}; std::array conversation_meta_labels_{}; std::array capability_labels_{}; std::array contact_rows_{}; std::array contact_name_labels_{}; std::array contact_meta_labels_{}; std::array chat_conversation_bindings_{}; std::array chat_conversation_buttons_{}; std::array chat_conversation_title_labels_{}; std::array chat_conversation_preview_labels_{}; std::array chat_conversation_meta_labels_{}; std::array chat_message_rows_{}; std::array chat_message_sender_labels_{}; std::array chat_message_text_labels_{}; std::array chat_message_meta_labels_{}; }; class UConsoleLvglHost { public: UConsoleLvglHost(UConsoleDesktopShell& shell, ::trailmate::cardputer_zero::platform::SurfacePresenter& presenter, UConsoleShellOptions options) : shell_(shell), presenter_(presenter), options_(validateOptions(options)), canvas_(options_.width, options_.height), frame_buffer_(static_cast(options_.width) * static_cast(options_.height), 0) { g_lvgl_start_time = clock::now(); lv_init(); lv_tick_set_cb(tickNow); display_ = lv_display_create(options_.width, options_.height); if (display_ == nullptr) { throw std::runtime_error( "Failed to create LVGL display for uConsole shell."); } lv_display_set_default(display_); lv_display_set_user_data(display_, this); lv_display_set_color_format(display_, LV_COLOR_FORMAT_RGB565); lv_display_set_buffers( display_, frame_buffer_.data(), nullptr, static_cast(frame_buffer_.size() * sizeof(std::uint16_t)), LV_DISPLAY_RENDER_MODE_FULL); lv_display_set_flush_cb(display_, flushCb); if (!shell_.begin()) { throw std::runtime_error("Failed to begin uConsole desktop shell."); } keypad_ = lv_indev_create(); if (keypad_ == nullptr) { throw std::runtime_error( "Failed to create LVGL keypad input for uConsole shell."); } lv_indev_set_type(keypad_, LV_INDEV_TYPE_KEYPAD); lv_indev_set_display(keypad_, display_); lv_indev_set_user_data(keypad_, this); lv_indev_set_read_cb(keypad_, readInputCb); if (shell_.inputGroup() != nullptr) { lv_indev_set_group(keypad_, shell_.inputGroup()); } if (presenter_.supportsPointer()) { pointer_ = lv_indev_create(); if (pointer_ == nullptr) { throw std::runtime_error( "Failed to create pointer input for uConsole shell."); } lv_indev_set_type(pointer_, LV_INDEV_TYPE_POINTER); lv_indev_set_display(pointer_, display_); lv_indev_set_user_data(pointer_, this); lv_indev_set_read_cb(pointer_, readPointerCb); } tick(); } ~UConsoleLvglHost() { if (keypad_ != nullptr) { lv_indev_delete(keypad_); keypad_ = nullptr; } if (pointer_ != nullptr) { lv_indev_delete(pointer_); pointer_ = nullptr; } shell_.releaseLvglObjects(); if (display_ != nullptr) { lv_display_delete(display_); display_ = nullptr; } lv_deinit(); } void tick() { shell_.tick(); lv_timer_handler(); if (dirty_) { copyFrameBufferToCanvas(); dirty_ = false; } } [[nodiscard]] const Canvas& canvas() const noexcept { return canvas_; } static void flushCb(lv_display_t* display, const lv_area_t* /*area*/, std::uint8_t* /*px_map*/) { auto* host = static_cast(lv_display_get_user_data(display)); if (host != nullptr) host->dirty_ = true; lv_display_flush_ready(display); } static void readInputCb(lv_indev_t* indev, lv_indev_data_t* data) { auto* host = static_cast(lv_indev_get_user_data(indev)); if (host == nullptr || data == nullptr) return; data->state = LV_INDEV_STATE_RELEASED; data->key = 0U; std::uint32_t key = 0U; lv_indev_state_t state = LV_INDEV_STATE_RELEASED; if (!host->shell_.dequeueKeyEvent(&key, &state)) return; data->state = state; data->key = key; data->continue_reading = host->shell_.hasPendingKeyEvent(); } static void readPointerCb(lv_indev_t* indev, lv_indev_data_t* data) { auto* host = static_cast(lv_indev_get_user_data(indev)); if (host == nullptr || data == nullptr) return; const auto pointer = host->presenter_.pointerState(); data->point.x = pointer.x; data->point.y = pointer.y; data->state = pointer.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; } private: void copyFrameBufferToCanvas() { for (int y = 0; y < options_.height; ++y) { for (int x = 0; x < options_.width; ++x) { const auto index = static_cast((y * options_.width) + x); canvas_.setPixel(x, y, rgb565ToColor(frame_buffer_[index])); } } } UConsoleDesktopShell& shell_; ::trailmate::cardputer_zero::platform::SurfacePresenter& presenter_; UConsoleShellOptions options_{}; lv_display_t* display_ = nullptr; lv_indev_t* keypad_ = nullptr; lv_indev_t* pointer_ = nullptr; Canvas canvas_; std::vector frame_buffer_{}; bool dirty_ = true; }; } // namespace void runUConsoleShell(::trailmate::cardputer_zero::platform::SurfacePresenter& presenter, UConsoleShellOptions options) { options = validateOptions(options); UConsoleDesktopShell shell; UConsoleLvglHost host{shell, presenter, options}; auto next_frame = clock::now(); const auto frame_time = std::chrono::milliseconds(options.frame_time_ms); while (presenter.pump()) { shell.enqueueInputs(presenter.drainInput()); host.tick(); presenter.present(host.canvas()); next_frame += frame_time; std::this_thread::sleep_until(next_frame); if (clock::now() > next_frame + std::chrono::milliseconds(250)) { next_frame = clock::now(); } } } } // namespace trailmate::uconsole