diff --git a/apps/linux_uconsole_gtk/CMakeLists.txt b/apps/linux_uconsole_gtk/CMakeLists.txt index 7ddce2a9..004d7e6a 100644 --- a/apps/linux_uconsole_gtk/CMakeLists.txt +++ b/apps/linux_uconsole_gtk/CMakeLists.txt @@ -16,6 +16,9 @@ set(TRAIL_MATE_UCONSOLE_PACKAGE_VERSION "0.1.0" option(TRAIL_MATE_UCONSOLE_BUILD_REAL_GTK_UI "Build the restored uConsole GTK executable when Linux GTK4 is available." ON) +option(TRAIL_MATE_UCONSOLE_BUILD_SDL_SCREENSHOT_CAPTURE + "Build the SDL-rendered uConsole screenshot capture tool." + OFF) set(TRAIL_MATE_UCONSOLE_REAL_GTK_SOURCES src/platform/gtk/gtk_uconsole_app.cpp @@ -23,6 +26,7 @@ set(TRAIL_MATE_UCONSOLE_REAL_GTK_SOURCES src/platform/gtk/gtk_uconsole_chat_logic.cpp src/platform/gtk/gtk_uconsole_data_layout.cpp src/platform/gtk/gtk_uconsole_data_logic.cpp + src/platform/gtk/gtk_uconsole_field_pages.cpp src/platform/gtk/gtk_uconsole_hardware_layout.cpp src/platform/gtk/gtk_uconsole_hardware_logic.cpp src/platform/gtk/gtk_uconsole_logs_layout.cpp @@ -33,6 +37,7 @@ set(TRAIL_MATE_UCONSOLE_REAL_GTK_SOURCES src/platform/gtk/gtk_uconsole_overview_layout.cpp src/platform/gtk/gtk_uconsole_overview_logic.cpp src/platform/gtk/gtk_uconsole_pages.cpp + src/platform/gtk/gtk_uconsole_radio_tools.cpp src/platform/gtk/gtk_uconsole_settings_layout.cpp src/platform/gtk/gtk_uconsole_settings_logic.cpp src/platform/gtk/gtk_uconsole_shell.cpp @@ -196,6 +201,73 @@ else() message(STATUS "Restored uConsole GTK executable is enabled only on Linux GTK4 hosts.") endif() +if(TRAIL_MATE_UCONSOLE_BUILD_SDL_SCREENSHOT_CAPTURE) + find_package(Threads REQUIRED) + find_package(SDL3 CONFIG REQUIRED) + + if(NOT TARGET lvgl) + set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) + set(LV_BUILD_CONF_DIR + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include" + CACHE PATH "LVGL configuration directory for uConsole SDL capture" + FORCE) + set(TRAIL_MATE_LOCAL_LVGL_DIR + "${TRAIL_MATE_REPO_ROOT}/managed_components/lvgl__lvgl") + if(EXISTS "${TRAIL_MATE_LOCAL_LVGL_DIR}/CMakeLists.txt") + add_subdirectory( + "${TRAIL_MATE_LOCAL_LVGL_DIR}" + "${CMAKE_BINARY_DIR}/managed_components/lvgl__lvgl" + EXCLUDE_FROM_ALL) + else() + message(FATAL_ERROR + "The uConsole SDL capture requires managed_components/lvgl__lvgl") + endif() + endif() + + include("${TRAIL_MATE_REPO_ROOT}/cmake/TrailMateLinuxSources.cmake") + trailmate_add_linux_common(trailmate_uconsole_sdl_linux_common) + target_sources(trailmate_uconsole_sdl_linux_common + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_message_ledger.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_outbox_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/mesh_peer_directory_core.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/read/chat_read_state_ledger.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/runtime/meshtastic_position_core.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/runtime/meshtastic_waypoint_core.cpp") + + add_library(trailmate_uconsole_sdl_shell + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_dashboard_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_desktop_shell.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_hardware_probe.cpp") + target_include_directories(trailmate_uconsole_sdl_shell + PUBLIC + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/include" + "${TRAIL_MATE_LINUX_COMMON_INCLUDE_ROOT}" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}") + target_compile_features(trailmate_uconsole_sdl_shell PUBLIC cxx_std_20) + target_compile_definitions(trailmate_uconsole_sdl_shell + PRIVATE TRAIL_MATE_UCONSOLE_LINUX=1) + target_link_libraries(trailmate_uconsole_sdl_shell + PUBLIC + lvgl + trailmate_uconsole_sdl_linux_common) + trailmate_apply_linux_common_warnings(trailmate_uconsole_sdl_shell) + + add_executable(trailmate_uconsole_sdl_screenshot_capture + src/platform/desktop/sdl_window_presenter.cpp + tools/uconsole_sdl_screenshot_capture.cpp) + target_include_directories(trailmate_uconsole_sdl_screenshot_capture + PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src") + target_compile_features(trailmate_uconsole_sdl_screenshot_capture + PRIVATE cxx_std_20) + target_link_libraries(trailmate_uconsole_sdl_screenshot_capture + PRIVATE + SDL3::SDL3 + trailmate_uconsole_sdl_shell) +endif() + if(BUILD_TESTING) add_executable(trailmate_linux_uconsole_gtk_app_shell_smoke tests/linux_uconsole_gtk_app_shell_smoke.cpp) diff --git a/apps/linux_uconsole_gtk/src/platform/desktop/sdl_window_presenter.cpp b/apps/linux_uconsole_gtk/src/platform/desktop/sdl_window_presenter.cpp index 524fe66a..ea2ed8c5 100644 --- a/apps/linux_uconsole_gtk/src/platform/desktop/sdl_window_presenter.cpp +++ b/apps/linux_uconsole_gtk/src/platform/desktop/sdl_window_presenter.cpp @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -14,6 +17,9 @@ #include "app/input_event.h" #include "core/canvas.h" +#define LODEPNG_NO_COMPILE_CPP +#include "src/libs/lodepng/lodepng.h" + namespace trailmate::uconsole::desktop { namespace @@ -142,6 +148,75 @@ void handleTextInput(std::vector& queue, } } +void saveRendererPng(SDL_Renderer* renderer, + const std::filesystem::path& path) +{ + SDL_Surface* captured = + requireSdl(SDL_RenderReadPixels(renderer, nullptr), + "SDL_RenderReadPixels"); + SDL_Surface* rgba = + requireSdl(SDL_ConvertSurface(captured, SDL_PIXELFORMAT_RGBA32), + "SDL_ConvertSurface"); + SDL_DestroySurface(captured); + + std::vector pixels; + pixels.resize(static_cast(rgba->w) * + static_cast(rgba->h) * 4U); + const auto* source = static_cast(rgba->pixels); + for (int y = 0; y < rgba->h; ++y) + { + const auto* source_row = + source + (static_cast(y) * + static_cast(rgba->pitch)); + auto* destination_row = + pixels.data() + (static_cast(y) * + static_cast(rgba->w) * 4U); + std::copy_n(source_row, + static_cast(rgba->w) * 4U, + destination_row); + } + + const int width = rgba->w; + const int height = rgba->h; + SDL_DestroySurface(rgba); + + if (!path.parent_path().empty()) + { + std::filesystem::create_directories(path.parent_path()); + } + + unsigned char* encoded = nullptr; + std::size_t encoded_size = 0; + const unsigned error = + lodepng_encode32(&encoded, + &encoded_size, + pixels.data(), + static_cast(width), + static_cast(height)); + if (error != 0U) + { + throw std::runtime_error("failed to encode SDL screenshot " + + path.string() + ": " + + lodepng_error_text(error)); + } + + std::ofstream output(path, std::ios::binary); + if (!output) + { + std::free(encoded); + throw std::runtime_error("failed to open SDL screenshot " + + path.string()); + } + output.write(reinterpret_cast(encoded), + static_cast(encoded_size)); + std::free(encoded); + if (!output) + { + throw std::runtime_error("failed to write SDL screenshot " + + path.string()); + } +} + } // namespace struct SdlWindowPresenter::Impl @@ -155,6 +230,9 @@ struct SdlWindowPresenter::Impl int texture_height = 0; int window_width = 0; int window_height = 0; + int presented_frames = 0; + bool startup_inputs_queued = false; + bool startup_shortcut_queued = false; std::vector staging{}; std::vector input_queue{}; }; @@ -171,8 +249,15 @@ SdlWindowPresenter::SdlWindowPresenter(SdlWindowOptions options) requireSdl(SDL_Init(SDL_INIT_VIDEO), "SDL_Init"); - const SDL_WindowFlags flags = - impl_->options.fullscreen ? SDL_WINDOW_FULLSCREEN : 0; + SDL_WindowFlags flags = 0; + if (impl_->options.fullscreen) + { + flags |= SDL_WINDOW_FULLSCREEN; + } + if (impl_->options.hidden) + { + flags |= SDL_WINDOW_HIDDEN; + } impl_->window = requireSdl(SDL_CreateWindow(impl_->options.title.c_str(), impl_->window_width, @@ -204,6 +289,28 @@ SdlWindowPresenter::~SdlWindowPresenter() bool SdlWindowPresenter::pump() { + if (!impl_->startup_inputs_queued) + { + for (int step = 0; step < impl_->options.initial_nav_steps; ++step) + { + enqueueSpecial(impl_->input_queue, app::InputKey::Tab, "TAB"); + } + if (impl_->options.initial_nav_steps > 0) + { + enqueueSpecial(impl_->input_queue, app::InputKey::Enter, "OK"); + } + impl_->startup_inputs_queued = true; + } + else if (!impl_->startup_shortcut_queued && impl_->presented_frames >= 5) + { + if (impl_->options.initial_shortcut != '\0') + { + impl_->input_queue.push_back( + app::makeCharacterInput(impl_->options.initial_shortcut)); + } + impl_->startup_shortcut_queued = true; + } + SDL_Event event{}; while (SDL_PollEvent(&event)) { @@ -306,6 +413,15 @@ void SdlWindowPresenter::present(const core::Canvas& canvas) &destination), "SDL_RenderTexture"); requireSdl(SDL_RenderPresent(impl_->renderer), "SDL_RenderPresent"); + + ++impl_->presented_frames; + if (!impl_->options.screenshot_path.empty() && + impl_->presented_frames >= + std::max(1, impl_->options.screenshot_after_frames)) + { + saveRendererPng(impl_->renderer, impl_->options.screenshot_path); + impl_->running = false; + } } } // namespace trailmate::uconsole::desktop diff --git a/apps/linux_uconsole_gtk/src/platform/desktop/sdl_window_presenter.h b/apps/linux_uconsole_gtk/src/platform/desktop/sdl_window_presenter.h index 3ac514cb..1c7e3d9b 100644 --- a/apps/linux_uconsole_gtk/src/platform/desktop/sdl_window_presenter.h +++ b/apps/linux_uconsole_gtk/src/platform/desktop/sdl_window_presenter.h @@ -15,6 +15,11 @@ struct SdlWindowOptions int scale = 1; bool fullscreen = false; std::string title = "Trail Mate uConsole"; + std::string screenshot_path{}; + int screenshot_after_frames = 45; + int initial_nav_steps = 0; + char initial_shortcut = '\0'; + bool hidden = false; }; class SdlWindowPresenter : public cardputer_zero::platform::SurfacePresenter diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_app_state.h b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_app_state.h index 2c55f199..ffdda376 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_app_state.h +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_app_state.h @@ -16,6 +16,7 @@ #include "app/linux_app_services.h" #include "gps/usecase/gnss_skyplot_presenter.h" #include "platform/linux/runtime_packet_log.h" +#include "platform/ui/pack_repository_runtime.h" #include "uconsole/uconsole_chat_workspace_model.h" #include "uconsole/uconsole_dashboard_model.h" #include "uconsole/uconsole_map_workspace_model.h" @@ -81,6 +82,12 @@ struct GtkUConsoleAppState GtkWidget* nav_chat = nullptr; GtkWidget* nav_chat_badge = nullptr; GtkWidget* nav_map = nullptr; + GtkWidget* nav_contacts = nullptr; + GtkWidget* nav_gps = nullptr; + GtkWidget* nav_team = nullptr; + GtkWidget* nav_tracker = nullptr; + GtkWidget* nav_radio_tools = nullptr; + GtkWidget* nav_extensions = nullptr; GtkWidget* nav_hardware = nullptr; GtkWidget* nav_data = nullptr; GtkWidget* nav_logs = nullptr; @@ -114,6 +121,14 @@ struct GtkUConsoleAppState GtkWidget* capability_box = nullptr; GtkWidget* hardware_page_box = nullptr; GtkWidget* data_page_box = nullptr; + GtkWidget* contacts_page_box = nullptr; + GtkWidget* gps_page_box = nullptr; + GtkWidget* gps_skyplot = nullptr; + GtkWidget* gps_satellite_list = nullptr; + GtkWidget* team_page_box = nullptr; + GtkWidget* tracker_page_box = nullptr; + GtkWidget* radio_tools_page_box = nullptr; + GtkWidget* extensions_page_box = nullptr; GtkWidget* logs_page_box = nullptr; GtkWidget* logs_source_gps = nullptr; GtkWidget* logs_source_lora = nullptr; @@ -280,6 +295,29 @@ struct GtkUConsoleAppState int overview_timeline_filter_index = 0; std::string settings_notice{}; int settings_notice_ticks = 0; + std::string team_action_status{}; + std::string tracker_action_status{}; + + struct RadioSweepPoint + { + float frequency_mhz = 0.0F; + float rssi_dbm = -140.0F; + }; + + struct RadioSweepResult + { + bool ok = false; + std::string message{}; + std::vector points{}; + }; + + std::future radio_sweep_future{}; + std::vector radio_sweep_points{}; + std::string radio_tools_status{}; + bool radio_sweep_running = false; + std::vector<::ui::runtime::packs::PackageRecord> extension_catalog{}; + std::string extensions_status{}; + bool extensions_loaded = false; std::vector page_lifecycle{}; std::string active_page{}; diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_data_layout.cpp b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_data_layout.cpp index a31874f7..6273dfae 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_data_layout.cpp +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_data_layout.cpp @@ -7,8 +7,8 @@ namespace trailmate::uconsole::gtk GtkWidget* launchDataLayout(GtkUConsoleAppState& state) { return buildDetailsWorkspace( - "Data", - "Local SQLite state, message/contact counts, and map cache.", + "Data & offline maps", + "Local SQLite state, automatic map downloads, cache health, and offline storage.", &state.data_page_box); } diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_data_logic.cpp b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_data_logic.cpp index 765ed767..40936cde 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_data_logic.cpp +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_data_logic.cpp @@ -1,11 +1,31 @@ #include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_shell.h" #include "platform/gtk/gtk_uconsole_widgets.h" +#include #include namespace trailmate::uconsole::gtk { +namespace +{ + +void onDataOpenMapClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "map"); +} + +void onDataRetryMapDownloadsClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.map_failed_tiles.clear(); + state.map_fetch_status = "state: retry requested"; + refreshUi(state); +} + +} // namespace + static void refreshDataPage(GtkUConsoleAppState& state, const UConsoleDashboardSnapshot& dashboard, const MapWorkspaceSnapshot& map_snapshot) @@ -41,6 +61,86 @@ static void refreshDataPage(GtkUConsoleAppState& state, .string())); gtk_box_append(GTK_BOX(state.data_page_box), strip); + const std::size_t visible_cached = static_cast(std::count_if( + map_snapshot.tiles.begin(), + map_snapshot.tiles.end(), + [](const MapTileItem& tile) + { return tile.available; })); + const std::size_t visible_total = map_snapshot.tiles.size(); + const std::size_t visible_missing = visible_total - visible_cached; + + GtkWidget* downloads = makePanel(); + gtk_widget_add_css_class(downloads, "map-download-panel"); + GtkWidget* download_header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* download_title = + makeLabel("Automatic offline map downloads", "pane-heading"); + gtk_widget_set_hexpand(download_title, TRUE); + gtk_box_append(GTK_BOX(download_header), download_title); + + GtkWidget* retry = gtk_button_new_with_label("Retry failed"); + gtk_widget_add_css_class(retry, "nav-button"); + gtk_widget_set_sensitive(retry, + state.map_failed_tiles.empty() ? FALSE : TRUE); + g_signal_connect(retry, + "clicked", + G_CALLBACK(onDataRetryMapDownloadsClicked), + &state); + gtk_box_append(GTK_BOX(download_header), retry); + + GtkWidget* open_map = gtk_button_new_with_label("Open map"); + gtk_widget_add_css_class(open_map, "nav-button"); + g_signal_connect(open_map, + "clicked", + G_CALLBACK(onDataOpenMapClicked), + &state); + gtk_box_append(GTK_BOX(download_header), open_map); + gtk_box_append(GTK_BOX(downloads), download_header); + + GtkWidget* download_metrics = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(download_metrics, "metric-strip"); + gtk_box_append(GTK_BOX(download_metrics), + makeMetricCard("Visible tiles", + std::to_string(visible_cached) + " / " + + std::to_string(visible_total), + std::to_string(visible_missing) + + " waiting", + visible_missing > 0)); + gtk_box_append(GTK_BOX(download_metrics), + makeMetricCard("Active downloads", + std::to_string(state.map_fetch_jobs.size()), + "Up to " + + std::to_string( + kMaxConcurrentMapFetches) + + " concurrent")); + gtk_box_append(GTK_BOX(download_metrics), + makeMetricCard("Retry queue", + std::to_string( + state.map_failed_tiles.size()), + state.map_failed_tiles.empty() + ? "Healthy" + : "Exponential backoff", + !state.map_failed_tiles.empty())); + gtk_box_append(GTK_BOX(download_metrics), + makeMetricCard("Base layer", + map_snapshot.source_label, + "Zoom " + + std::to_string(map_snapshot.zoom))); + gtk_box_append(GTK_BOX(downloads), download_metrics); + + const std::string download_state = + state.map_fetch_status.empty() + ? "Idle; missing tiles are fetched automatically around the current map viewport." + : state.map_fetch_status; + gtk_box_append(GTK_BOX(downloads), + buildDetailRow("Background worker", + download_state, + !state.map_failed_tiles.empty())); + gtk_box_append(GTK_BOX(downloads), + buildDetailRow( + "Cache policy", + "Fetch visible base tiles on demand, keep them on disk, and reuse them offline.")); + gtk_box_append(GTK_BOX(state.data_page_box), downloads); + GtkWidget* detail = makePanel(); gtk_widget_add_css_class(detail, "inspector-pane"); gtk_box_append(GTK_BOX(detail), diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_field_pages.cpp b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_field_pages.cpp new file mode 100644 index 00000000..f799816d --- /dev/null +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_field_pages.cpp @@ -0,0 +1,647 @@ +#include "platform/gtk/gtk_uconsole_layout_spec.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_shell.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include +#include +#include +#include +#include + +#include "gps/usecase/gnss_skyplot_presenter.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/tracker_runtime.h" + +namespace trailmate::uconsole::gtk +{ +namespace +{ + +constexpr std::size_t kFieldConversationLimit = 64; +constexpr std::size_t kFieldSatelliteLimit = 32; + +::chat::NodeId nodeIdFromButton(GtkButton* button) +{ + return static_cast<::chat::NodeId>(GPOINTER_TO_UINT( + g_object_get_data(G_OBJECT(button), "trailmate-node-id"))); +} + +GtkWidget* makeFieldAction(const char* label, + const ChatNodeInfoItem& item, + GCallback callback, + GtkUConsoleAppState& state, + bool enabled = true) +{ + GtkWidget* button = gtk_button_new_with_label(label); + gtk_widget_add_css_class(button, "chat-node-action"); + g_object_set_data(G_OBJECT(button), + "trailmate-node-id", + GUINT_TO_POINTER(static_cast(item.node_id))); + gtk_widget_set_sensitive(button, enabled ? TRUE : FALSE); + g_signal_connect(button, "clicked", callback, &state); + return button; +} + +void onFieldNodeChatClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.selectNodeConversation(nodeIdFromButton(button)); + showPage(state, "chat"); +} + +GtkWidget* makeContactNodeCard(GtkUConsoleAppState& state, + const ChatNodeInfoItem& item) +{ + GtkWidget* card = makePanel(); + gtk_widget_add_css_class(card, "field-contact-card"); + + GtkWidget* title_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* title = makeLabel(item.title.c_str(), "row-title", true); + gtk_widget_set_hexpand(title, TRUE); + gtk_box_append(GTK_BOX(title_row), title); + gtk_box_append(GTK_BOX(title_row), + makeLabel(item.via_mqtt ? "MQTT" : "LoRa", "mini-chip")); + gtk_box_append(GTK_BOX(card), title_row); + gtk_box_append(GTK_BOX(card), + makeLabel(item.subtitle.c_str(), "row-meta", true)); + gtk_box_append(GTK_BOX(card), + makeLabel(item.status.c_str(), "row-meta", true)); + gtk_box_append(GTK_BOX(card), + makeLabel(item.signal.c_str(), "row-meta", true)); + gtk_box_append(GTK_BOX(card), + makeLabel(item.position.c_str(), + item.has_position ? "chat-node-position" + : "row-meta", + true)); + + GtkWidget* actions = gtk_flow_box_new(); + gtk_flow_box_set_selection_mode(GTK_FLOW_BOX(actions), GTK_SELECTION_NONE); + gtk_flow_box_set_max_children_per_line(GTK_FLOW_BOX(actions), 3); + gtk_flow_box_set_row_spacing(GTK_FLOW_BOX(actions), 4); + gtk_flow_box_set_column_spacing(GTK_FLOW_BOX(actions), 4); + gtk_flow_box_append(GTK_FLOW_BOX(actions), + makeFieldAction("Chat", + item, + G_CALLBACK(onFieldNodeChatClicked), + state)); + gtk_flow_box_append(GTK_FLOW_BOX(actions), + makeFieldAction(item.is_contact ? "Added" : "Add", + item, + G_CALLBACK(onChatNodeAddClicked), + state, + !item.is_contact)); + gtk_flow_box_append(GTK_FLOW_BOX(actions), + makeFieldAction("Info", + item, + G_CALLBACK(onChatNodeInfoClicked), + state)); + gtk_flow_box_append( + GTK_FLOW_BOX(actions), + makeFieldAction(item.is_ignored ? "Unignore" : "Ignore", + item, + G_CALLBACK(onChatNodeIgnoreClicked), + state)); + gtk_flow_box_append(GTK_FLOW_BOX(actions), + makeFieldAction("Exchange", + item, + G_CALLBACK( + onChatNodeExchangeUserInfoClicked), + state)); + gtk_flow_box_append( + GTK_FLOW_BOX(actions), + makeFieldAction(item.key_verified ? "Trusted" : "Verify key", + item, + G_CALLBACK(onChatNodeVerifyKeyClicked), + state, + !item.key_verified)); + gtk_box_append(GTK_BOX(card), actions); + return card; +} + +void appendNodeGroup(GtkUConsoleAppState& state, + GtkWidget* panel, + const std::vector& nodes, + bool contacts) +{ + bool appended = false; + for (const auto& item : nodes) + { + if (item.is_contact != contacts) + { + continue; + } + gtk_box_append(GTK_BOX(panel), makeContactNodeCard(state, item)); + appended = true; + } + if (!appended) + { + gtk_box_append(GTK_BOX(panel), + makeLabel(contacts ? "No saved contacts yet." + : "No nearby nodes yet.", + "empty-state")); + } +} + +GtkWidget* launchContactsLayout(GtkUConsoleAppState& state) +{ + return buildDetailsWorkspace( + "Contacts & nodes", + "Desktop node directory with messaging, trust, ignore, and user-info actions.", + &state.contacts_page_box); +} + +void refreshContactsLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + clearBox(state.contacts_page_box); + const ChatWorkspaceSnapshot chat = state.chat_model.snapshot( + kFieldConversationLimit, 0, state.chat_sort_mode); + + GtkWidget* metrics = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(metrics, "metric-strip"); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Contacts", + std::to_string( + snapshot.dashboard.contact_count), + "Saved identities")); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Nearby", + std::to_string( + snapshot.dashboard.nearby_count), + "Recently discovered")); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Ignored", + std::to_string( + snapshot.dashboard.ignored_count), + "Hidden from normal lists", + snapshot.dashboard.ignored_count > 0)); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Protocol", + snapshot.dashboard.mesh_protocol, + std::to_string(chat.nodes.size()) + + " decoded nodes")); + gtk_box_append(GTK_BOX(state.contacts_page_box), metrics); + + GtkWidget* workbench = makeWorkbench(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* contacts = makePanel(); + GtkWidget* nearby = makePanel(); + gtk_widget_set_hexpand(contacts, TRUE); + gtk_widget_set_hexpand(nearby, TRUE); + gtk_box_append(GTK_BOX(contacts), + makeLabel("Saved contacts", "pane-heading")); + gtk_box_append(GTK_BOX(nearby), + makeLabel("Nearby and discovered", "pane-heading")); + appendNodeGroup(state, contacts, chat.nodes, true); + appendNodeGroup(state, nearby, chat.nodes, false); + gtk_box_append(GTK_BOX(workbench), contacts); + gtk_box_append(GTK_BOX(workbench), nearby); + gtk_box_append(GTK_BOX(state.contacts_page_box), workbench); +} + +GtkWidget* makeSatelliteRow(const ::gps::GnssSkyplotSatellite& sat) +{ + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_add_css_class(row, "gnss-sat-row"); + gtk_widget_add_css_class(row, sat.used ? "gnss-sat-used" + : "gnss-sat-unused"); + char title[32] = {}; + std::snprintf(title, + sizeof(title), + "%s %u", + ::gps::gnss_system_label(sat.system), + static_cast(sat.id)); + char detail[96] = {}; + std::snprintf(detail, + sizeof(detail), + "%s / SNR %d / elevation %u / azimuth %u", + sat.used ? "used" : "in view", + static_cast(sat.snr), + static_cast(sat.elevation), + static_cast(sat.azimuth)); + gtk_box_append(GTK_BOX(row), makeLabel(title, "gnss-sat-title")); + gtk_box_append(GTK_BOX(row), + makeLabel(detail, "gnss-sat-meta", true)); + return row; +} + +void onFieldOpenMapClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "map"); +} + +GtkWidget* launchGpsLayout(GtkUConsoleAppState& state) +{ + return buildDetailsWorkspace( + "GPS & sky plot", + "Live fix, satellite geometry, receiver state, and map handoff.", + &state.gps_page_box); +} + +void refreshGpsLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + clearBox(state.gps_page_box); + + std::array<::gps::GnssSatInfo, ::gps::kMaxGnssSats> sats{}; + std::size_t sat_count = 0; + ::gps::GnssStatus status{}; + const bool has_snapshot = ::platform::ui::gps::get_gnss_snapshot( + sats.data(), sats.size(), &sat_count, &status); + const auto gps_state = ::platform::ui::gps::get_data(); + state.overview_gnss_view = ::gps::build_gnss_skyplot_view( + sats.data(), + sat_count, + status, + gps_state, + has_snapshot, + kFieldSatelliteLimit); + + GtkWidget* metrics = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(metrics, "metric-strip"); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Fix", + snapshot.dashboard.location.state, + snapshot.dashboard.location.coordinates, + snapshot.dashboard.location.attention)); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Satellites used", + std::to_string( + state.overview_gnss_view.status + .sats_in_use), + std::to_string( + state.overview_gnss_view.status + .sats_in_view) + + " in view")); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Map zoom", + std::to_string(snapshot.map.zoom), + snapshot.map.source_label)); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Receiver", + has_snapshot ? "Live" : "Waiting", + snapshot.dashboard.location.detail, + !has_snapshot)); + gtk_box_append(GTK_BOX(state.gps_page_box), metrics); + + GtkWidget* workbench = makeWorkbench(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* sky_panel = makePanel(); + gtk_widget_set_hexpand(sky_panel, TRUE); + gtk_box_append(GTK_BOX(sky_panel), + makeLabel("Satellite sky plot", "pane-heading")); + state.gps_skyplot = gtk_drawing_area_new(); + gtk_widget_add_css_class(state.gps_skyplot, "gnss-skyplot"); + gtk_widget_set_size_request(state.gps_skyplot, 440, 360); + gtk_widget_set_hexpand(state.gps_skyplot, TRUE); + gtk_widget_set_vexpand(state.gps_skyplot, TRUE); + gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(state.gps_skyplot), + drawOverviewGnssSkyplot, + &state, + nullptr); + gtk_box_append(GTK_BOX(sky_panel), state.gps_skyplot); + + GtkWidget* satellite_panel = makePanel(); + gtk_widget_set_size_request(satellite_panel, 320, -1); + gtk_box_append(GTK_BOX(satellite_panel), + makeLabel("Satellites", "pane-heading")); + state.gps_satellite_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + if (state.overview_gnss_view.satellites.empty()) + { + gtk_box_append(GTK_BOX(state.gps_satellite_list), + makeLabel("No satellite data.", "empty-state")); + } + for (const auto& sat : state.overview_gnss_view.satellites) + { + gtk_box_append(GTK_BOX(state.gps_satellite_list), + makeSatelliteRow(sat)); + } + GtkWidget* satellite_scroll = gtk_scrolled_window_new(); + gtk_widget_set_vexpand(satellite_scroll, TRUE); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(satellite_scroll), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(satellite_scroll), + state.gps_satellite_list); + gtk_box_append(GTK_BOX(satellite_panel), satellite_scroll); + + GtkWidget* open_map = gtk_button_new_with_label("Open map workspace"); + gtk_widget_add_css_class(open_map, "nav-button"); + g_signal_connect(open_map, + "clicked", + G_CALLBACK(onFieldOpenMapClicked), + &state); + gtk_box_append(GTK_BOX(satellite_panel), open_map); + gtk_box_append(GTK_BOX(workbench), sky_panel); + gtk_box_append(GTK_BOX(workbench), satellite_panel); + gtk_box_append(GTK_BOX(state.gps_page_box), workbench); +} + +const ChatConversationItem* firstTeamConversation( + const ChatWorkspaceSnapshot& snapshot) +{ + const auto it = std::find_if( + snapshot.conversations.begin(), + snapshot.conversations.end(), + [](const ChatConversationItem& item) + { return item.team; }); + return it == snapshot.conversations.end() ? nullptr : &*it; +} + +void onTeamOpenChatClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + const auto snapshot = state.chat_model.snapshot(kFieldConversationLimit, 0); + const auto* conversation = firstTeamConversation(snapshot); + if (conversation == nullptr) + { + state.team_action_status = "No team conversation is available yet."; + refreshUi(state); + return; + } + state.chat_model.selectConversation(conversation->id); + showPage(state, "chat"); +} + +void onTeamSharePositionClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + const auto snapshot = state.chat_model.snapshot(kFieldConversationLimit, 0); + const auto* conversation = firstTeamConversation(snapshot); + if (conversation == nullptr) + { + state.team_action_status = "Join or create a team before sharing."; + } + else + { + state.chat_model.selectConversation(conversation->id); + state.team_action_status = state.chat_model.sendCurrentPosition() + ? "Current position queued for the team." + : "Position could not be queued."; + } + refreshUi(state); +} + +GtkWidget* launchTeamLayout(GtkUConsoleAppState& state) +{ + return buildDetailsWorkspace( + "Team operations", + "Team state, field activity, team chat, and position sharing.", + &state.team_page_box); +} + +void showTeamPage(GtkUConsoleAppState& state) +{ + state.services.setTeamModeActive(true); +} + +void hideTeamPage(GtkUConsoleAppState& state) +{ + state.services.setTeamModeActive(false); +} + +void refreshTeamLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + clearBox(state.team_page_box); + const auto chat = state.chat_model.snapshot(kFieldConversationLimit, 0); + const bool has_team_chat = firstTeamConversation(chat) != nullptr; + + GtkWidget* summary = makePanel(); + gtk_box_append(GTK_BOX(summary), + makeLabel("Team status", "pane-heading")); + gtk_box_append(GTK_BOX(summary), + makeLabel(snapshot.dashboard.team_summary.c_str(), + "summary-detail", + true)); + if (!state.team_action_status.empty()) + { + gtk_box_append(GTK_BOX(summary), + makeLabel(state.team_action_status.c_str(), + "settings-status", + true)); + } + + GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* open_chat = gtk_button_new_with_label("Open team chat"); + GtkWidget* share = gtk_button_new_with_label("Share current position"); + gtk_widget_add_css_class(open_chat, "nav-button"); + gtk_widget_add_css_class(share, "nav-button"); + gtk_widget_set_sensitive(open_chat, has_team_chat ? TRUE : FALSE); + gtk_widget_set_sensitive(share, has_team_chat ? TRUE : FALSE); + g_signal_connect(open_chat, + "clicked", + G_CALLBACK(onTeamOpenChatClicked), + &state); + g_signal_connect(share, + "clicked", + G_CALLBACK(onTeamSharePositionClicked), + &state); + gtk_box_append(GTK_BOX(actions), open_chat); + gtk_box_append(GTK_BOX(actions), share); + gtk_box_append(GTK_BOX(summary), actions); + gtk_box_append(GTK_BOX(state.team_page_box), summary); + + GtkWidget* timeline = makePanel(); + gtk_box_append(GTK_BOX(timeline), + makeLabel("Team activity", "pane-heading")); + if (snapshot.dashboard.team_timeline.empty()) + { + gtk_box_append(GTK_BOX(timeline), + makeLabel("No team activity has been recorded.", + "empty-state")); + } + for (const auto& item : snapshot.dashboard.team_timeline) + { + gtk_box_append(GTK_BOX(timeline), + buildDetailRow(item.title, + item.detail, + item.attention)); + } + gtk_box_append(GTK_BOX(state.team_page_box), timeline); +} + +const char* trackerFormatLabel(std::uint8_t value) +{ + switch (value) + { + case 1: + return "CSV"; + case 2: + return "Binary"; + case 0: + default: + return "GPX"; + } +} + +void onTrackerToggleClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + auto& config = state.services.config(); + if (::platform::ui::tracker::is_recording()) + { + ::platform::ui::tracker::stop_recording(); + config.map_track_enabled = false; + state.services.applyPositionConfig(); + state.tracker_action_status = "Track recording stopped."; + } + else + { + config.map_track_enabled = true; + state.services.applyPositionConfig(); + const bool started = ::platform::ui::tracker::start_recording(); + state.tracker_action_status = started + ? "Track recording started." + : "Track file could not be created."; + if (!started) + { + config.map_track_enabled = false; + state.services.applyPositionConfig(); + } + } + state.services.saveConfig(); + refreshUi(state); +} + +GtkWidget* launchTrackerLayout(GtkUConsoleAppState& state) +{ + return buildDetailsWorkspace( + "Tracker", + "Linux-native GPX, CSV, or binary track recording with persistent files.", + &state.tracker_page_box); +} + +void refreshTrackerLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + clearBox(state.tracker_page_box); + const auto& config = state.services.config(); + const bool supported = ::platform::ui::tracker::is_supported(); + const bool recording = ::platform::ui::tracker::is_recording(); + std::string current_path{}; + (void)::platform::ui::tracker::current_path(current_path); + + GtkWidget* metrics = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(metrics, "metric-strip"); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Recorder", + recording ? "Recording" : "Stopped", + supported ? "Linux track writer ready" + : "Track directory unavailable", + !supported)); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Format", + trackerFormatLabel( + config.map_track_format), + "Persistent setting")); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Interval", + std::to_string( + config.map_track_interval) + + " s", + "Sampling cadence")); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("GPS", + snapshot.dashboard.location.state, + snapshot.dashboard.location.coordinates, + snapshot.dashboard.location.attention)); + gtk_box_append(GTK_BOX(state.tracker_page_box), metrics); + + GtkWidget* controls = makePanel(); + GtkWidget* header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* title = makeLabel("Recording session", "pane-heading"); + gtk_widget_set_hexpand(title, TRUE); + gtk_box_append(GTK_BOX(header), title); + GtkWidget* toggle = gtk_button_new_with_label( + recording ? "Stop recording" : "Start recording"); + gtk_widget_add_css_class(toggle, "nav-button"); + gtk_widget_set_sensitive(toggle, supported ? TRUE : FALSE); + g_signal_connect(toggle, + "clicked", + G_CALLBACK(onTrackerToggleClicked), + &state); + gtk_box_append(GTK_BOX(header), toggle); + gtk_box_append(GTK_BOX(controls), header); + gtk_box_append(GTK_BOX(controls), + buildDetailRow("Current file", + current_path.empty() ? "No active file" + : current_path)); + gtk_box_append(GTK_BOX(controls), + buildDetailRow("Track directory", + ::platform::ui::tracker::track_dir())); + if (!state.tracker_action_status.empty()) + { + gtk_box_append(GTK_BOX(controls), + makeLabel(state.tracker_action_status.c_str(), + "settings-status", + true)); + } + gtk_box_append(GTK_BOX(state.tracker_page_box), controls); + + std::vector tracks{}; + (void)::platform::ui::tracker::list_tracks(tracks, 32); + GtkWidget* history = makePanel(); + gtk_box_append(GTK_BOX(history), + makeLabel("Recorded tracks", "pane-heading")); + if (tracks.empty()) + { + gtk_box_append(GTK_BOX(history), + makeLabel("No track files yet.", "empty-state")); + } + for (const auto& track : tracks) + { + gtk_box_append(GTK_BOX(history), + buildDetailRow(track, + std::string("Stored in ") + + ::platform::ui::tracker::track_dir())); + } + gtk_box_append(GTK_BOX(state.tracker_page_box), history); +} + +} // namespace + +GtkUConsolePageLifecycle makeContactsPageLifecycle() +{ + return {.name = "contacts", + .title = "Contacts & nodes", + .onLaunch = launchContactsLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshContactsLogic, + .onDestroy = nullptr}; +} + +GtkUConsolePageLifecycle makeGpsPageLifecycle() +{ + return {.name = "gps", + .title = "GPS & sky plot", + .onLaunch = launchGpsLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshGpsLogic, + .onDestroy = nullptr}; +} + +GtkUConsolePageLifecycle makeTeamPageLifecycle() +{ + return {.name = "team", + .title = "Team operations", + .onLaunch = launchTeamLayout, + .onShow = showTeamPage, + .onHide = hideTeamPage, + .onRefresh = refreshTeamLogic, + .onDestroy = nullptr}; +} + +GtkUConsolePageLifecycle makeTrackerPageLifecycle() +{ + return {.name = "tracker", + .title = "Tracker", + .onLaunch = launchTrackerLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshTrackerLogic, + .onDestroy = nullptr}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_layout_spec.h b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_layout_spec.h index 5a054485..0643304c 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_layout_spec.h +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_layout_spec.h @@ -7,6 +7,7 @@ namespace trailmate::uconsole::gtk::layout_spec // Update the specification before changing product geometry here. constexpr int kGlobalStatusBarHeight = 28; +constexpr int kNavigationRailWidth = 184; constexpr int kOverviewGpsRailWidth = 208; constexpr int kOverviewTimelineRailWidth = 252; diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_map_logic.cpp b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_map_logic.cpp index cb44e82f..71ac24a7 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_map_logic.cpp +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_map_logic.cpp @@ -1243,9 +1243,6 @@ GtkWidget* buildContourTileCell(const MapWorkspaceSnapshot& snapshot, void refreshMap(GtkUConsoleAppState& state, const MapWorkspaceSnapshot& snapshot) { - pollContourFill(state); - maybeStartMapFetch(state, snapshot); - setActiveMapSourceButton( state.map_source_osm, ::platform::linux_runtime::MapBaseSource::Osm, @@ -1439,6 +1436,13 @@ void refreshMap(GtkUConsoleAppState& state, } } +void serviceMapDownloads(GtkUConsoleAppState& state, + const MapWorkspaceSnapshot& snapshot) +{ + pollContourFill(state); + maybeStartMapFetch(state, snapshot); +} + void refreshMap(GtkUConsoleAppState& state) { refreshMap(state, state.map_model.snapshot()); diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_pages.cpp b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_pages.cpp index 9d690fe2..a48be0ef 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_pages.cpp +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_pages.cpp @@ -7,7 +7,13 @@ std::vector buildGtkUConsolePageRegistry() { return {makeOverviewPageLifecycle(), makeChatPageLifecycle(), + makeContactsPageLifecycle(), makeMapPageLifecycle(), + makeGpsPageLifecycle(), + makeTeamPageLifecycle(), + makeTrackerPageLifecycle(), + makeRadioToolsPageLifecycle(), + makeExtensionsPageLifecycle(), makeHardwarePageLifecycle(), makeDataPageLifecycle(), makeLogsPageLifecycle(), diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_pages.h b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_pages.h index 0c4da9ab..5fcd9be5 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_pages.h +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_pages.h @@ -50,6 +50,8 @@ GtkUConsolePageLifecycle makeMapPageLifecycle(); GtkWidget* launchMapLayout(GtkUConsoleAppState& state); void refreshMapLogic(GtkUConsoleAppState& state, const GtkUConsoleRefreshSnapshot& snapshot); +void serviceMapDownloads(GtkUConsoleAppState& state, + const MapWorkspaceSnapshot& snapshot); void refreshMap(GtkUConsoleAppState& state); void refreshMap(GtkUConsoleAppState& state, const MapWorkspaceSnapshot& snapshot); void onMapSourceClicked(GtkButton* button, gpointer data); @@ -96,6 +98,13 @@ GtkWidget* launchDataLayout(GtkUConsoleAppState& state); void refreshDataLogic(GtkUConsoleAppState& state, const GtkUConsoleRefreshSnapshot& snapshot); +GtkUConsolePageLifecycle makeContactsPageLifecycle(); +GtkUConsolePageLifecycle makeGpsPageLifecycle(); +GtkUConsolePageLifecycle makeTeamPageLifecycle(); +GtkUConsolePageLifecycle makeTrackerPageLifecycle(); +GtkUConsolePageLifecycle makeRadioToolsPageLifecycle(); +GtkUConsolePageLifecycle makeExtensionsPageLifecycle(); + GtkUConsolePageLifecycle makeLogsPageLifecycle(); GtkWidget* launchLogsLayout(GtkUConsoleAppState& state); void refreshLogsLogic(GtkUConsoleAppState& state, diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_radio_tools.cpp b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_radio_tools.cpp new file mode 100644 index 00000000..adc4dbe6 --- /dev/null +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_radio_tools.cpp @@ -0,0 +1,700 @@ +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_shell.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "chat/infra/meshtastic/mt_region.h" +#include "platform/ui/capability_status.h" +#include "platform/ui/lora_runtime.h" +#include "platform/ui/pack_repository_runtime.h" +#include "platform/ui/sstv_runtime.h" +#include "platform/ui/walkie_runtime.h" + +namespace trailmate::uconsole::gtk +{ +namespace +{ + +constexpr std::size_t kSweepPointCount = 48; +constexpr auto kSweepSettleTime = std::chrono::milliseconds(12); + +struct SweepPlan +{ + float start_mhz = 433.0F; + float end_mhz = 434.8F; + ::platform::ui::lora::ReceiveConfig receive{}; +}; + +std::string capabilityLabel(const ::platform::ui::CapabilityStatus& status) +{ + std::string label = ::platform::ui::capability_state_label(status.state); + if (status.message != nullptr && status.message[0] != '\0') + { + label += " — "; + label += status.message; + } + return label; +} + +bool capabilityNeedsAttention(const ::platform::ui::CapabilityStatus& status) +{ + return status.state == ::platform::ui::CapabilityState::Unsupported || + status.state == ::platform::ui::CapabilityState::Degraded || + status.state == ::platform::ui::CapabilityState::Error; +} + +SweepPlan makeSweepPlan(const GtkUConsoleAppState& state) +{ + SweepPlan plan{}; + const auto& config = state.services.config(); + const auto& mesh = config.mesh_protocol == ::chat::MeshProtocol::Meshtastic + ? config.meshtastic_config + : config.mesh_protocol == ::chat::MeshProtocol::MeshCore + ? config.meshcore_config + : config.reticulumConfig(); + plan.receive.bw_khz = displayBandwidthKHz(mesh, config.mesh_protocol); + plan.receive.sf = displaySpreadFactor(mesh, config.mesh_protocol); + plan.receive.cr = displayCodingRate(mesh, config.mesh_protocol); + plan.receive.tx_power = static_cast(displayTxPowerDbm(mesh)); + + if (config.mesh_protocol == ::chat::MeshProtocol::Meshtastic) + { + auto region_code = static_cast( + mesh.region); + if (region_code == + meshtastic_Config_LoRaConfig_RegionCode_UNSET) + { + region_code = meshtastic_Config_LoRaConfig_RegionCode_CN; + } + if (const auto* region = ::chat::meshtastic::findRegion(region_code); + region != nullptr) + { + plan.start_mhz = region->freq_start_mhz; + plan.end_mhz = region->freq_end_mhz; + return plan; + } + } + + const float center = displayFrequencyMhz(mesh); + if (std::isfinite(center) && center > 0.0F) + { + plan.start_mhz = center - 1.0F; + plan.end_mhz = center + 1.0F; + } + return plan; +} + +GtkUConsoleAppState::RadioSweepResult runSweep(SweepPlan plan) +{ + GtkUConsoleAppState::RadioSweepResult result{}; + if (!::platform::ui::lora::acquire()) + { + result.message = "LoRa receiver could not be acquired."; + return result; + } + + struct RadioReleaseGuard + { + ~RadioReleaseGuard() + { + ::platform::ui::lora::release(); + } + } guard; + + result.points.reserve(kSweepPointCount); + for (std::size_t index = 0; index < kSweepPointCount; ++index) + { + const float ratio = static_cast(index) / + static_cast(kSweepPointCount - 1); + const float frequency = + plan.start_mhz + ((plan.end_mhz - plan.start_mhz) * ratio); + if (!::platform::ui::lora::configure_receive(frequency, + plan.receive)) + { + result.message = "LoRa receive configuration failed during scan."; + return result; + } + std::this_thread::sleep_for(kSweepSettleTime); + const float rssi = ::platform::ui::lora::read_instant_rssi(); + if (std::isfinite(rssi)) + { + result.points.push_back({frequency, rssi}); + } + } + + result.ok = !result.points.empty(); + result.message = result.ok ? "Sweep complete; mesh radio configuration restored." + : "No valid RSSI samples were returned."; + return result; +} + +void drawSpectrum(GtkDrawingArea*, + cairo_t* cr, + int width, + int height, + gpointer data) +{ + const auto& state = *static_cast(data); + cairo_set_source_rgb(cr, 0.035, 0.055, 0.075); + cairo_paint(cr); + + constexpr double margin = 24.0; + const double graph_width = std::max(1.0, width - (margin * 2.0)); + const double graph_height = std::max(1.0, height - (margin * 2.0)); + cairo_set_line_width(cr, 1.0); + cairo_set_source_rgba(cr, 0.25, 0.45, 0.55, 0.35); + for (int line = 0; line <= 4; ++line) + { + const double y = margin + (graph_height * line / 4.0); + cairo_move_to(cr, margin, y); + cairo_line_to(cr, margin + graph_width, y); + } + cairo_stroke(cr); + + if (state.radio_sweep_points.empty()) + { + return; + } + + cairo_set_line_width(cr, 2.2); + cairo_set_source_rgb(cr, 0.20, 0.86, 0.73); + for (std::size_t index = 0; index < state.radio_sweep_points.size(); ++index) + { + const auto& point = state.radio_sweep_points[index]; + const double x = margin + graph_width * static_cast(index) / + static_cast( + state.radio_sweep_points.size() - 1); + const double normalized = std::clamp( + (static_cast(point.rssi_dbm) + 140.0) / 112.0, + 0.0, + 1.0); + const double y = margin + graph_height * (1.0 - normalized); + if (index == 0) + { + cairo_move_to(cr, x, y); + } + else + { + cairo_line_to(cr, x, y); + } + } + cairo_stroke(cr); +} + +void drawSstvFrame(GtkDrawingArea*, + cairo_t* cr, + int width, + int height, + gpointer) +{ + cairo_set_source_rgb(cr, 0.025, 0.035, 0.05); + cairo_paint(cr); + const std::uint16_t* pixels = ::platform::ui::sstv::framebuffer(); + const auto frame_width = ::platform::ui::sstv::frame_width(); + const auto frame_height = ::platform::ui::sstv::frame_height(); + if (pixels == nullptr || frame_width == 0 || frame_height == 0) + { + return; + } + + const double scale = std::min(static_cast(width) / frame_width, + static_cast(height) / frame_height); + const double left = (width - (frame_width * scale)) / 2.0; + const double top = (height - (frame_height * scale)) / 2.0; + constexpr std::uint16_t sample_step = 3; + for (std::uint16_t y = 0; y < frame_height; y += sample_step) + { + for (std::uint16_t x = 0; x < frame_width; x += sample_step) + { + const std::uint16_t rgb565 = + pixels[static_cast(y) * frame_width + x]; + const double red = ((rgb565 >> 11U) & 0x1FU) / 31.0; + const double green = ((rgb565 >> 5U) & 0x3FU) / 63.0; + const double blue = (rgb565 & 0x1FU) / 31.0; + cairo_set_source_rgb(cr, red, green, blue); + cairo_rectangle(cr, + left + (x * scale), + top + (y * scale), + sample_step * scale + 0.5, + sample_step * scale + 0.5); + cairo_fill(cr); + } + } +} + +void onStartSweepClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (state.radio_sweep_running) + { + return; + } + state.radio_sweep_running = true; + state.radio_tools_status = "Scanning the configured radio band..."; + const SweepPlan plan = makeSweepPlan(state); + state.radio_sweep_future = std::async( + std::launch::async, + [plan]() + { return runSweep(plan); }); + refreshUi(state); +} + +void onSstvToggleClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (::platform::ui::sstv::is_active()) + { + ::platform::ui::sstv::stop(); + state.radio_tools_status = "SSTV receiver stopped."; + } + else + { + state.radio_tools_status = ::platform::ui::sstv::start() + ? "SSTV receiver started." + : ::platform::ui::sstv::last_error(); + } + refreshUi(state); +} + +void onWalkieToggleClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (::platform::ui::walkie::is_active()) + { + ::platform::ui::walkie::set_ptt(false); + ::platform::ui::walkie::stop(); + state.radio_tools_status = "Walkie session stopped."; + } + else + { + state.radio_tools_status = ::platform::ui::walkie::start() + ? "Walkie session started." + : ::platform::ui::walkie::last_error(); + } + refreshUi(state); +} + +void onWalkiePttClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + const auto status = ::platform::ui::walkie::get_status(); + ::platform::ui::walkie::set_ptt(!status.tx); + refreshUi(state); +} + +void onWalkieMonitorClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + (void)::platform::ui::walkie::set_monitor_enabled( + !::platform::ui::walkie::monitor_enabled()); + refreshUi(state); +} + +void pollSweep(GtkUConsoleAppState& state) +{ + if (!state.radio_sweep_running || !state.radio_sweep_future.valid() || + state.radio_sweep_future.wait_for(std::chrono::seconds(0)) != + std::future_status::ready) + { + return; + } + auto result = state.radio_sweep_future.get(); + state.radio_sweep_running = false; + state.radio_tools_status = std::move(result.message); + if (result.ok) + { + state.radio_sweep_points = std::move(result.points); + } +} + +GtkWidget* launchRadioToolsLayout(GtkUConsoleAppState& state) +{ + return buildDetailsWorkspace( + "Radio tools", + "Desktop workbench for spectrum inspection, SSTV, and walkie controls.", + &state.radio_tools_page_box); +} + +void refreshRadioToolsLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot&) +{ + pollSweep(state); + clearBox(state.radio_tools_page_box); + + const auto lora_capability = ::platform::ui::lora::capability_status(); + const auto sstv_capability = ::platform::ui::sstv::capability_status(); + const auto walkie_capability = ::platform::ui::walkie::capability_status(); + const auto sstv_status = ::platform::ui::sstv::get_status(); + const auto walkie_status = ::platform::ui::walkie::get_status(); + + GtkWidget* metrics = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(metrics, "metric-strip"); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("LoRa sweep", + state.radio_sweep_running ? "Scanning" + : "Ready", + capabilityLabel(lora_capability), + capabilityNeedsAttention(lora_capability))); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("SSTV", + ::platform::ui::sstv::is_active() + ? "Receiving" + : "Idle", + capabilityLabel(sstv_capability), + capabilityNeedsAttention(sstv_capability))); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Walkie", + walkie_status.tx + ? "Transmitting" + : (walkie_status.active ? "Listening" + : "Stopped"), + capabilityLabel(walkie_capability), + capabilityNeedsAttention( + walkie_capability))); + gtk_box_append(GTK_BOX(state.radio_tools_page_box), metrics); + + GtkWidget* spectrum = makePanel(); + GtkWidget* spectrum_header = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* spectrum_title = makeLabel("Energy sweep", "pane-heading"); + gtk_widget_set_hexpand(spectrum_title, TRUE); + gtk_box_append(GTK_BOX(spectrum_header), spectrum_title); + GtkWidget* sweep = gtk_button_new_with_label( + state.radio_sweep_running ? "Scanning..." : "Run sweep"); + gtk_widget_add_css_class(sweep, "nav-button"); + gtk_widget_set_sensitive( + sweep, + !state.radio_sweep_running && ::platform::ui::lora::is_supported()); + g_signal_connect(sweep, + "clicked", + G_CALLBACK(onStartSweepClicked), + &state); + gtk_box_append(GTK_BOX(spectrum_header), sweep); + gtk_box_append(GTK_BOX(spectrum), spectrum_header); + GtkWidget* graph = gtk_drawing_area_new(); + gtk_widget_add_css_class(graph, "radio-spectrum"); + gtk_widget_set_size_request(graph, -1, 210); + gtk_widget_set_hexpand(graph, TRUE); + gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(graph), + drawSpectrum, + &state, + nullptr); + gtk_box_append(GTK_BOX(spectrum), graph); + if (!state.radio_sweep_points.empty()) + { + const auto strongest = std::max_element( + state.radio_sweep_points.begin(), + state.radio_sweep_points.end(), + [](const auto& lhs, const auto& rhs) + { return lhs.rssi_dbm < rhs.rssi_dbm; }); + char summary[128] = {}; + std::snprintf(summary, + sizeof(summary), + "%.3f–%.3f MHz / strongest %.3f MHz at %.1f dBm", + static_cast( + state.radio_sweep_points.front().frequency_mhz), + static_cast( + state.radio_sweep_points.back().frequency_mhz), + static_cast(strongest->frequency_mhz), + static_cast(strongest->rssi_dbm)); + gtk_box_append(GTK_BOX(spectrum), + makeLabel(summary, "row-meta", true)); + } + gtk_box_append(GTK_BOX(state.radio_tools_page_box), spectrum); + + GtkWidget* lower = makeWorkbench(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* sstv = makePanel(); + GtkWidget* sstv_header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* sstv_title = makeLabel("SSTV receiver", "pane-heading"); + gtk_widget_set_hexpand(sstv_title, TRUE); + gtk_box_append(GTK_BOX(sstv_header), sstv_title); + GtkWidget* sstv_toggle = gtk_button_new_with_label( + ::platform::ui::sstv::is_active() ? "Stop" : "Start"); + gtk_widget_add_css_class(sstv_toggle, "nav-button"); + gtk_widget_set_sensitive(sstv_toggle, + ::platform::ui::sstv::is_supported()); + g_signal_connect(sstv_toggle, + "clicked", + G_CALLBACK(onSstvToggleClicked), + &state); + gtk_box_append(GTK_BOX(sstv_header), sstv_toggle); + gtk_box_append(GTK_BOX(sstv), sstv_header); + GtkWidget* sstv_preview = gtk_drawing_area_new(); + gtk_widget_set_size_request(sstv_preview, 360, 230); + gtk_widget_set_hexpand(sstv_preview, TRUE); + gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(sstv_preview), + drawSstvFrame, + nullptr, + nullptr); + gtk_box_append(GTK_BOX(sstv), sstv_preview); + gtk_box_append(GTK_BOX(sstv), + buildDetailRow( + "Progress", + std::to_string(static_cast( + std::clamp(sstv_status.progress, 0.0F, 1.0F) * + 100.0F)) + + "% / " + ::platform::ui::sstv::mode_name())); + const char* saved_path = ::platform::ui::sstv::last_saved_path(); + gtk_box_append(GTK_BOX(sstv), + buildDetailRow("Last image", + saved_path != nullptr && saved_path[0] != '\0' + ? saved_path + : "No decoded image yet")); + + GtkWidget* walkie = makePanel(); + GtkWidget* walkie_header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* walkie_title = makeLabel("Walkie", "pane-heading"); + gtk_widget_set_hexpand(walkie_title, TRUE); + gtk_box_append(GTK_BOX(walkie_header), walkie_title); + GtkWidget* walkie_toggle = gtk_button_new_with_label( + walkie_status.active ? "Stop session" : "Start session"); + gtk_widget_add_css_class(walkie_toggle, "nav-button"); + gtk_widget_set_sensitive(walkie_toggle, + ::platform::ui::walkie::is_supported()); + g_signal_connect(walkie_toggle, + "clicked", + G_CALLBACK(onWalkieToggleClicked), + &state); + gtk_box_append(GTK_BOX(walkie_header), walkie_toggle); + gtk_box_append(GTK_BOX(walkie), walkie_header); + gtk_box_append(GTK_BOX(walkie), + buildDetailRow("Frequency", + std::to_string(walkie_status.freq_mhz) + + " MHz")); + gtk_box_append(GTK_BOX(walkie), + buildDetailRow("Levels", + "RX " + + std::to_string(walkie_status.rx_level) + + " / TX " + + std::to_string(walkie_status.tx_level))); + gtk_box_append(GTK_BOX(walkie), + buildDetailRow("Volume", + std::to_string( + ::platform::ui::walkie::volume()))); + GtkWidget* walkie_actions = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* ptt = gtk_button_new_with_label(walkie_status.tx + ? "Release PTT" + : "Press PTT"); + GtkWidget* monitor = gtk_button_new_with_label( + walkie_status.monitor_enabled ? "Monitor on" : "Monitor off"); + gtk_widget_add_css_class(ptt, "nav-button"); + gtk_widget_add_css_class(monitor, "nav-button"); + gtk_widget_set_sensitive(ptt, walkie_status.active); + gtk_widget_set_sensitive(monitor, walkie_status.active); + g_signal_connect(ptt, + "clicked", + G_CALLBACK(onWalkiePttClicked), + &state); + g_signal_connect(monitor, + "clicked", + G_CALLBACK(onWalkieMonitorClicked), + &state); + gtk_box_append(GTK_BOX(walkie_actions), ptt); + gtk_box_append(GTK_BOX(walkie_actions), monitor); + gtk_box_append(GTK_BOX(walkie), walkie_actions); + gtk_box_append(GTK_BOX(lower), sstv); + gtk_box_append(GTK_BOX(lower), walkie); + gtk_box_append(GTK_BOX(state.radio_tools_page_box), lower); + + if (!state.radio_tools_status.empty()) + { + gtk_box_append(GTK_BOX(state.radio_tools_page_box), + makeLabel(state.radio_tools_status.c_str(), + "settings-status", + true)); + } +} + +void hideRadioTools(GtkUConsoleAppState&) +{ + ::platform::ui::walkie::set_ptt(false); +} + +void destroyRadioTools(GtkUConsoleAppState& state) +{ + ::platform::ui::walkie::set_ptt(false); + ::platform::ui::walkie::stop(); + ::platform::ui::sstv::stop(); + if (state.radio_sweep_future.valid()) + { + state.radio_sweep_future.wait(); + (void)state.radio_sweep_future.get(); + } + state.radio_sweep_running = false; +} + +void loadExtensionCatalog(GtkUConsoleAppState& state) +{ + if (state.extensions_loaded) + { + return; + } + state.extension_catalog.clear(); + std::string error{}; + state.extensions_loaded = + ::ui::runtime::packs::fetch_catalog(state.extension_catalog, error); + state.extensions_status = state.extensions_loaded + ? std::to_string( + state.extension_catalog.size()) + + " packages available locally." + : error; +} + +const char* packageIdFromButton(GtkButton* button) +{ + return static_cast( + g_object_get_data(G_OBJECT(button), "trailmate-package-id")); +} + +void onExtensionActionClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + const char* package_id = packageIdFromButton(button); + const auto it = std::find_if( + state.extension_catalog.begin(), + state.extension_catalog.end(), + [package_id](const auto& package) + { return package.id == (package_id == nullptr ? "" : package_id); }); + if (it == state.extension_catalog.end()) + { + return; + } + + std::string error{}; + const bool ok = it->installed + ? ::ui::runtime::packs::uninstall_package(*it, error) + : ::ui::runtime::packs::start_install_package(*it, + error); + state.extensions_status = ok + ? it->display_name + + (it->installed ? " uninstalled." + : " installed.") + : error; + state.extensions_loaded = false; + refreshUi(state); +} + +GtkWidget* launchExtensionsLayout(GtkUConsoleAppState& state) +{ + return buildDetailsWorkspace( + "Extensions", + "Desktop package workspace backed by the existing Trail Mate pack repository.", + &state.extensions_page_box); +} + +void refreshExtensionsLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot&) +{ + loadExtensionCatalog(state); + clearBox(state.extensions_page_box); + + std::size_t installed = 0; + for (const auto& package : state.extension_catalog) + { + installed += package.installed ? 1U : 0U; + } + GtkWidget* metrics = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(metrics, "metric-strip"); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Catalog", + std::to_string( + state.extension_catalog.size()), + ::ui::runtime::packs::is_supported() + ? "Local package source ready" + : "Package source unavailable", + !::ui::runtime::packs::is_supported())); + gtk_box_append(GTK_BOX(metrics), + makeMetricCard("Installed", + std::to_string(installed), + "Persisted desktop package state")); + gtk_box_append(GTK_BOX(state.extensions_page_box), metrics); + + GtkWidget* catalog = makePanel(); + gtk_box_append(GTK_BOX(catalog), + makeLabel("Available packages", "pane-heading")); + if (state.extension_catalog.empty()) + { + gtk_box_append(GTK_BOX(catalog), + makeLabel("No packages are available.", + "empty-state")); + } + for (const auto& package : state.extension_catalog) + { + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(row, "row"); + GtkWidget* details = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_set_hexpand(details, TRUE); + gtk_box_append(GTK_BOX(details), + makeLabel(package.display_name.c_str(), + "row-title")); + gtk_box_append(GTK_BOX(details), + makeLabel((package.version + " / " + + package.package_type + " / " + + formatBytes(package.archive_size_bytes)) + .c_str(), + "row-meta")); + gtk_box_append(GTK_BOX(details), + makeLabel(package.summary.c_str(), + "row-meta", + true)); + gtk_box_append(GTK_BOX(row), details); + GtkWidget* action = gtk_button_new_with_label( + package.installed ? "Uninstall" : "Install"); + gtk_widget_add_css_class(action, "nav-button"); + g_object_set_data_full(G_OBJECT(action), + "trailmate-package-id", + g_strdup(package.id.c_str()), + g_free); + g_signal_connect(action, + "clicked", + G_CALLBACK(onExtensionActionClicked), + &state); + gtk_box_append(GTK_BOX(row), action); + gtk_box_append(GTK_BOX(catalog), row); + } + gtk_box_append(GTK_BOX(state.extensions_page_box), catalog); + if (!state.extensions_status.empty()) + { + gtk_box_append(GTK_BOX(state.extensions_page_box), + makeLabel(state.extensions_status.c_str(), + "settings-status", + true)); + } +} + +} // namespace + +GtkUConsolePageLifecycle makeRadioToolsPageLifecycle() +{ + return {.name = "radio-tools", + .title = "Radio tools", + .onLaunch = launchRadioToolsLayout, + .onShow = nullptr, + .onHide = hideRadioTools, + .onRefresh = refreshRadioToolsLogic, + .onDestroy = destroyRadioTools}; +} + +GtkUConsolePageLifecycle makeExtensionsPageLifecycle() +{ + return {.name = "extensions", + .title = "Extensions", + .onLaunch = launchExtensionsLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshExtensionsLogic, + .onDestroy = nullptr}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_shell.cpp b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_shell.cpp index 1826833e..be1d9d1f 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_shell.cpp +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_shell.cpp @@ -59,7 +59,13 @@ void setActiveNav(GtkUConsoleAppState& state, const char* page) apply(state.nav_overview, "overview"); apply(state.nav_chat, "chat"); + apply(state.nav_contacts, "contacts"); apply(state.nav_map, "map"); + apply(state.nav_gps, "gps"); + apply(state.nav_team, "team"); + apply(state.nav_tracker, "tracker"); + apply(state.nav_radio_tools, "radio-tools"); + apply(state.nav_extensions, "extensions"); apply(state.nav_hardware, "hardware"); apply(state.nav_data, "data"); apply(state.nav_logs, "logs"); @@ -81,6 +87,36 @@ void onMapClicked(GtkButton*, gpointer data) showPage(*static_cast(data), "map"); } +void onContactsClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "contacts"); +} + +void onGpsClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "gps"); +} + +void onTeamClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "team"); +} + +void onTrackerClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "tracker"); +} + +void onRadioToolsClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "radio-tools"); +} + +void onExtensionsClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "extensions"); +} + void onHardwareClicked(GtkButton*, gpointer data) { showPage(*static_cast(data), "hardware"); @@ -101,24 +137,48 @@ void onSettingsClicked(GtkButton*, gpointer data) showPage(*static_cast(data), "settings"); } +void styleNavigationButton(GtkWidget* button) +{ + gtk_widget_add_css_class(button, "menu-button"); + gtk_widget_set_halign(button, GTK_ALIGN_FILL); + if (GtkWidget* child = gtk_button_get_child(GTK_BUTTON(button)); + child != nullptr && GTK_IS_LABEL(child)) + { + gtk_label_set_xalign(GTK_LABEL(child), 0.0F); + } +} + GtkWidget* buildMenuBar(GtkUConsoleAppState& state) { - GtkWidget* bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12); - gtk_widget_add_css_class(bar, "menu-bar"); - gtk_widget_set_hexpand(bar, TRUE); + GtkWidget* bar = gtk_box_new(GTK_ORIENTATION_VERTICAL, 3); + gtk_widget_add_css_class(bar, "navigation-rail"); + gtk_widget_set_size_request(bar, layout_spec::kNavigationRailWidth, -1); + gtk_widget_set_hexpand(bar, FALSE); + gtk_widget_set_vexpand(bar, TRUE); - gtk_box_append(GTK_BOX(bar), makeLabel("Trail Mate", "menu-title")); + GtkWidget* brand = gtk_box_new(GTK_ORIENTATION_VERTICAL, 1); + gtk_widget_add_css_class(brand, "navigation-brand"); + gtk_box_append(GTK_BOX(brand), makeLabel("TRAIL MATE", "menu-title")); + gtk_box_append(GTK_BOX(brand), + makeLabel("uConsole field desk", "navigation-subtitle")); + gtk_box_append(GTK_BOX(bar), brand); + + gtk_box_append(GTK_BOX(bar), + makeLabel("WORKSPACES", "navigation-section-label")); state.nav_overview = gtk_button_new_with_label("Overview"); - gtk_widget_add_css_class(state.nav_overview, "menu-button"); + styleNavigationButton(state.nav_overview); g_signal_connect(state.nav_overview, "clicked", G_CALLBACK(onOverviewClicked), &state); gtk_box_append(GTK_BOX(bar), state.nav_overview); state.nav_chat = gtk_button_new(); - gtk_widget_add_css_class(state.nav_chat, "menu-button"); + styleNavigationButton(state.nav_chat); GtkWidget* chat_nav_child = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); - gtk_box_append(GTK_BOX(chat_nav_child), makeLabel("Chat")); + GtkWidget* chat_nav_label = makeLabel("Chat"); + gtk_widget_set_hexpand(chat_nav_label, TRUE); + gtk_label_set_xalign(GTK_LABEL(chat_nav_label), 0.0F); + gtk_box_append(GTK_BOX(chat_nav_child), chat_nav_label); state.nav_chat_badge = makeLabel("", "menu-badge"); gtk_label_set_xalign(GTK_LABEL(state.nav_chat_badge), 0.5F); gtk_widget_set_visible(state.nav_chat_badge, FALSE); @@ -129,41 +189,100 @@ GtkWidget* buildMenuBar(GtkUConsoleAppState& state) gtk_box_append(GTK_BOX(bar), state.nav_chat); state.nav_map = gtk_button_new_with_label("Map"); - gtk_widget_add_css_class(state.nav_map, "menu-button"); + styleNavigationButton(state.nav_map); g_signal_connect(state.nav_map, "clicked", G_CALLBACK(onMapClicked), &state); gtk_box_append(GTK_BOX(bar), state.nav_map); + gtk_box_append(GTK_BOX(bar), + makeLabel("FIELD", "navigation-section-label")); + + state.nav_contacts = gtk_button_new_with_label("Contacts & nodes"); + styleNavigationButton(state.nav_contacts); + g_signal_connect(state.nav_contacts, + "clicked", + G_CALLBACK(onContactsClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_contacts); + + state.nav_gps = gtk_button_new_with_label("GPS & sky plot"); + styleNavigationButton(state.nav_gps); + g_signal_connect(state.nav_gps, + "clicked", + G_CALLBACK(onGpsClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_gps); + + state.nav_team = gtk_button_new_with_label("Team operations"); + styleNavigationButton(state.nav_team); + g_signal_connect(state.nav_team, + "clicked", + G_CALLBACK(onTeamClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_team); + + state.nav_tracker = gtk_button_new_with_label("Tracker"); + styleNavigationButton(state.nav_tracker); + g_signal_connect(state.nav_tracker, + "clicked", + G_CALLBACK(onTrackerClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_tracker); + + state.nav_radio_tools = gtk_button_new_with_label("Radio tools"); + styleNavigationButton(state.nav_radio_tools); + g_signal_connect(state.nav_radio_tools, + "clicked", + G_CALLBACK(onRadioToolsClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_radio_tools); + + gtk_box_append(GTK_BOX(bar), + makeLabel("SYSTEM", "navigation-section-label")); + state.nav_hardware = gtk_button_new_with_label("Hardware"); - gtk_widget_add_css_class(state.nav_hardware, "menu-button"); + styleNavigationButton(state.nav_hardware); g_signal_connect(state.nav_hardware, "clicked", G_CALLBACK(onHardwareClicked), &state); gtk_box_append(GTK_BOX(bar), state.nav_hardware); - state.nav_data = gtk_button_new_with_label("Data"); - gtk_widget_add_css_class(state.nav_data, "menu-button"); + state.nav_data = gtk_button_new_with_label("Data & maps"); + styleNavigationButton(state.nav_data); g_signal_connect(state.nav_data, "clicked", G_CALLBACK(onDataClicked), &state); gtk_box_append(GTK_BOX(bar), state.nav_data); + state.nav_extensions = gtk_button_new_with_label("Extensions"); + styleNavigationButton(state.nav_extensions); + g_signal_connect(state.nav_extensions, + "clicked", + G_CALLBACK(onExtensionsClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_extensions); + state.nav_logs = gtk_button_new_with_label("Logs"); - gtk_widget_add_css_class(state.nav_logs, "menu-button"); + styleNavigationButton(state.nav_logs); g_signal_connect(state.nav_logs, "clicked", G_CALLBACK(onLogsClicked), &state); gtk_box_append(GTK_BOX(bar), state.nav_logs); state.nav_settings = gtk_button_new_with_label("Settings"); - gtk_widget_add_css_class(state.nav_settings, "menu-button"); + styleNavigationButton(state.nav_settings); g_signal_connect(state.nav_settings, "clicked", G_CALLBACK(onSettingsClicked), &state); gtk_box_append(GTK_BOX(bar), state.nav_settings); GtkWidget* spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_widget_set_hexpand(spacer, TRUE); + gtk_widget_set_vexpand(spacer, TRUE); gtk_box_append(GTK_BOX(bar), spacer); - GtkWidget* hint = makeLabel("uConsole Linux", "chip"); - gtk_box_append(GTK_BOX(bar), hint); + GtkWidget* hint_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_add_css_class(hint_box, "navigation-footer"); + gtk_box_append(GTK_BOX(hint_box), + makeLabel("DESKTOP MODE", "navigation-footer-title")); + gtk_box_append(GTK_BOX(hint_box), + makeLabel("Keyboard + pointer", "navigation-subtitle")); + gtk_box_append(GTK_BOX(bar), hint_box); return bar; } @@ -232,13 +351,13 @@ GtkWidget* buildRoot(GtkUConsoleAppState& state) GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_widget_set_hexpand(root, TRUE); gtk_widget_set_vexpand(root, TRUE); - gtk_box_append(GTK_BOX(root), buildMenuBar(state)); GtkWidget* body = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_widget_add_css_class(body, "body"); gtk_widget_set_hexpand(body, TRUE); gtk_widget_set_vexpand(body, TRUE); gtk_widget_set_overflow(body, GTK_OVERFLOW_HIDDEN); + gtk_box_append(GTK_BOX(body), buildMenuBar(state)); state.page_lifecycle = buildGtkUConsolePageRegistry(); state.stack = gtk_stack_new(); @@ -255,28 +374,7 @@ GtkWidget* buildRoot(GtkUConsoleAppState& state) } gtk_box_append(GTK_BOX(body), state.stack); - GtkWidget* body_viewport = gtk_scrolled_window_new(); - gtk_widget_set_hexpand(body_viewport, TRUE); - gtk_widget_set_vexpand(body_viewport, TRUE); - gtk_widget_set_size_request(body_viewport, 1, 1); - gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_viewport), - GTK_POLICY_EXTERNAL, - GTK_POLICY_EXTERNAL); - gtk_scrolled_window_set_min_content_height( - GTK_SCROLLED_WINDOW(body_viewport), - 1); - gtk_scrolled_window_set_min_content_width( - GTK_SCROLLED_WINDOW(body_viewport), - 1); - gtk_scrolled_window_set_propagate_natural_height( - GTK_SCROLLED_WINDOW(body_viewport), - FALSE); - gtk_scrolled_window_set_propagate_natural_width( - GTK_SCROLLED_WINDOW(body_viewport), - FALSE); - gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_viewport), body); - - gtk_box_append(GTK_BOX(root), body_viewport); + gtk_box_append(GTK_BOX(root), body); gtk_box_append(GTK_BOX(root), buildStatusBar(state)); showPage(state, "overview"); return root; @@ -284,9 +382,11 @@ GtkWidget* buildRoot(GtkUConsoleAppState& state) void refreshUi(GtkUConsoleAppState& state) { - const GtkUConsoleRefreshSnapshot snapshot{ + GtkUConsoleRefreshSnapshot snapshot{ .dashboard = state.dashboard_model.snapshot(), .map = state.map_model.snapshot()}; + serviceMapDownloads(state, snapshot.map); + snapshot.map = state.map_model.snapshot(); setStatusChip(state.status_aio2, findHardware(snapshot.dashboard, "AIO2")); setStatusChip(state.status_lora, findHardware(snapshot.dashboard, "LoRa")); setStatusChip(state.status_gps, findHardware(snapshot.dashboard, "GPS")); diff --git a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_style.cpp b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_style.cpp index 3c9b6e1c..b727c8ae 100644 --- a/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_style.cpp +++ b/apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_style.cpp @@ -10,24 +10,52 @@ window { background: #e8ebe5; color: #1b211e; } -.menu-bar { +.navigation-rail { background: #1d2221; color: #f6f8f4; - padding: 3px 8px; - min-height: 28px; - border-bottom: 1px solid #111514; + padding: 10px 9px 9px 9px; + border-right: 1px solid #111514; +} +.navigation-brand { + padding: 2px 7px 8px 7px; +} +.navigation-subtitle { + color: #9da9a3; + font-size: 11px; +} +.navigation-section-label { + color: #7f8e87; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + padding: 7px 7px 2px 7px; +} +.navigation-footer { + border-top: 1px solid #39413e; + padding: 7px 7px 2px 7px; +} +.navigation-footer-title { + color: #d5ddd8; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; } .menu-title { color: #f8faf7; font-weight: 700; - padding: 0 6px; + font-size: 16px; } .menu-button { background: transparent; color: #dce3de; border: 1px solid transparent; - border-radius: 4px; - padding: 3px 8px; + border-radius: 5px; + padding: 5px 9px; + min-height: 18px; +} +.menu-button:hover { + background: #2a312f; + border-color: #3d4844; } .menu-badge { background: #dfeee7; @@ -63,9 +91,9 @@ window { padding: 4px 9px; } .nav-button-active { - background: #dfeee7; - color: #12241e; - border-color: #6f9f8d; + background: #d9e8df; + color: #10271f; + border-color: #7ca18f; font-weight: 700; } .workbench { diff --git a/apps/linux_uconsole_gtk/tools/uconsole_sdl_screenshot_capture.cpp b/apps/linux_uconsole_gtk/tools/uconsole_sdl_screenshot_capture.cpp new file mode 100644 index 00000000..7717b110 --- /dev/null +++ b/apps/linux_uconsole_gtk/tools/uconsole_sdl_screenshot_capture.cpp @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include +#include + +#include "platform/desktop/sdl_window_presenter.h" +#include "uconsole/uconsole_desktop_shell.h" + +namespace +{ + +struct CaptureTarget +{ + const char* id = nullptr; + const char* filename = nullptr; + int nav_steps = 0; + char shortcut = '\0'; +}; + +constexpr CaptureTarget kTargets[] = { + {"overview", "overview.png", 0, '\0'}, + {"chat", "chat.png", 1, '\0'}, + {"map", "map.png", 2, '\0'}, + {"gps", "gps-sky-plot.png", 4, '\0'}, + {"radio-tools", "radio-tools.png", 7, '\0'}, + {"extensions", "extensions.png", 10, '\0'}, + {"sidebar-collapsed", "sidebar-collapsed.png", 2, '\\'}, + {"shortcut-help", "shortcut-help.png", 0, 'h'}, +}; + +void setEnvironment(const char* name, const std::string& value) +{ +#if defined(_WIN32) + if (_putenv_s(name, value.c_str()) != 0) + { + throw std::runtime_error(std::string("failed to set ") + name); + } +#else + if (setenv(name, value.c_str(), 1) != 0) + { + throw std::runtime_error(std::string("failed to set ") + name); + } +#endif +} + +void configureCaptureRuntime() +{ + const std::filesystem::path runtime_root = + std::filesystem::current_path() / ".codex-build" / + "uconsole-sdl-runtime"; + setEnvironment("TRAIL_MATE_RUNTIME_MODE", "demo"); + setEnvironment("TRAIL_MATE_SETTINGS_ROOT", + (runtime_root / "settings").string()); + setEnvironment("TRAIL_MATE_SD_ROOT", (runtime_root / "sd").string()); + setEnvironment("TRAIL_MATE_CACHE_ROOT", (runtime_root / "cache").string()); +} + +void captureTarget(const CaptureTarget& target, + const std::filesystem::path& output_directory) +{ + const std::filesystem::path screenshot = + output_directory / target.filename; + trailmate::uconsole::desktop::SdlWindowPresenter presenter{ + {.width = 1280, + .height = 720, + .scale = 1, + .fullscreen = false, + .title = std::string("Trail Mate uConsole SDL - ") + target.id, + .screenshot_path = screenshot.string(), + .screenshot_after_frames = 45, + .initial_nav_steps = target.nav_steps, + .initial_shortcut = target.shortcut, + .hidden = true}}; + trailmate::uconsole::runUConsoleShell( + presenter, {.width = 1280, .height = 720, .frame_time_ms = 16}); + if (!std::filesystem::exists(screenshot)) + { + throw std::runtime_error("SDL capture did not create " + + screenshot.string()); + } + std::cout << target.id << ": " << screenshot.string() << '\n'; +} + +} // namespace + +int main(int argc, char** argv) +{ + try + { + const std::filesystem::path output_directory = + argc > 1 ? std::filesystem::path(argv[1]) + : std::filesystem::path("docs/specs/images/uconsole-sdl"); + std::filesystem::create_directories(output_directory); + configureCaptureRuntime(); + for (const auto& target : kTargets) + { + captureTarget(target, output_directory); + } + return 0; + } + catch (const std::exception& error) + { + std::cerr << "uConsole SDL capture failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/docs/specs/images/uconsole-sdl/chat.png b/docs/specs/images/uconsole-sdl/chat.png new file mode 100644 index 00000000..931da959 Binary files /dev/null and b/docs/specs/images/uconsole-sdl/chat.png differ diff --git a/docs/specs/images/uconsole-sdl/extensions.png b/docs/specs/images/uconsole-sdl/extensions.png new file mode 100644 index 00000000..ef18dc08 Binary files /dev/null and b/docs/specs/images/uconsole-sdl/extensions.png differ diff --git a/docs/specs/images/uconsole-sdl/gps-sky-plot.png b/docs/specs/images/uconsole-sdl/gps-sky-plot.png new file mode 100644 index 00000000..0788078c Binary files /dev/null and b/docs/specs/images/uconsole-sdl/gps-sky-plot.png differ diff --git a/docs/specs/images/uconsole-sdl/map.png b/docs/specs/images/uconsole-sdl/map.png new file mode 100644 index 00000000..da0f0028 Binary files /dev/null and b/docs/specs/images/uconsole-sdl/map.png differ diff --git a/docs/specs/images/uconsole-sdl/overview.png b/docs/specs/images/uconsole-sdl/overview.png new file mode 100644 index 00000000..c5633e89 Binary files /dev/null and b/docs/specs/images/uconsole-sdl/overview.png differ diff --git a/docs/specs/images/uconsole-sdl/radio-tools.png b/docs/specs/images/uconsole-sdl/radio-tools.png new file mode 100644 index 00000000..ade68ac1 Binary files /dev/null and b/docs/specs/images/uconsole-sdl/radio-tools.png differ diff --git a/docs/specs/images/uconsole-sdl/shortcut-help.png b/docs/specs/images/uconsole-sdl/shortcut-help.png new file mode 100644 index 00000000..f78f0541 Binary files /dev/null and b/docs/specs/images/uconsole-sdl/shortcut-help.png differ diff --git a/docs/specs/images/uconsole-sdl/sidebar-collapsed.png b/docs/specs/images/uconsole-sdl/sidebar-collapsed.png new file mode 100644 index 00000000..237afff1 Binary files /dev/null and b/docs/specs/images/uconsole-sdl/sidebar-collapsed.png differ diff --git a/docs/specs/uconsole-feature-parity.md b/docs/specs/uconsole-feature-parity.md new file mode 100644 index 00000000..685878b6 --- /dev/null +++ b/docs/specs/uconsole-feature-parity.md @@ -0,0 +1,65 @@ +# uConsole desktop feature parity + +This document records the uConsole desktop feature audit against the ESP32 +application catalog. The desktop shell reuses the existing Trail Mate domain, +protocol, radio, GPS, tracker, map, and package runtimes. It does not introduce +or depend on `meshtasticd`; Meshtastic traffic continues through Trail Mate's +native implementation. + +## Interaction model + +- Target geometry: 1280 x 720 landscape with keyboard and pointer. +- A 168 px navigation rail that can be collapsed with `\`, task-focused desktop + workspaces, and a persistent 30 px keyboard/status bar. +- T-Deck visual language is retained through the embedded cream, amber, brown, + gold-border, and blue/green status palette, while compact cards and strong + active-state contrast are adapted to uConsole density. +- `F1`/`H` opens a Pager/T-Deck-style shortcut reference; direct workspace + letters and `[`/`]` navigation keep every feature reachable without touch. +- Related low-frequency ESP screens are combined where a desktop workbench is + more efficient. Energy Sweep, SSTV, and Walkie share **Radio tools** while + keeping their independent runtimes and controls. + +## Feature matrix + +| ESP32 capability | uConsole desktop surface | Backend reuse | Status | +| --- | --- | --- | --- | +| Dashboard | Overview | uConsole dashboard model | Implemented | +| Chat | Chat workspace | Native Trail Mate Meshtastic/MeshCore/Reticulum services | Implemented | +| Contacts and nearby nodes | Contacts & nodes | Chat workspace model and node actions | Implemented | +| Map | Map workspace | Linux map cache, tile fetcher, contour store | Implemented | +| Automatic map download | Global background map service and Data & maps status | Existing asynchronous tile cache/fetch runtime | Implemented; desktop-specific behavior retained | +| GPS and Sky Plot | GPS & sky plot | GPS runtime and GNSS sky-plot presenter | Implemented | +| Team | Team operations | Dashboard team snapshot and chat model | Implemented | +| Tracker | Tracker | Linux GPX/CSV/binary tracker runtime | Implemented; saved settings now drive runtime | +| Energy Sweep | Radio tools | Existing LoRa runtime | Implemented; scan restores the active radio configuration | +| SSTV | Radio tools | Existing SSTV runtime | Implemented; capability state identifies simulated backends | +| Walkie | Radio tools | Existing Walkie runtime | Implemented; capability state identifies simulated backends | +| Extensions | Extensions | Existing pack repository runtime | Implemented | +| Settings | Settings | Existing app config and service apply paths | Implemented | +| Network | Settings and global status | Existing MQTT/network configuration | Implemented as desktop-integrated settings | +| USB disk | Data & maps / native Linux filesystem | Linux storage paths | Desktop equivalent; ESP USB mass-storage mode is not applicable | +| Poweroff | Native window/session lifecycle | GTK application shutdown | Desktop equivalent | +| Hardware diagnostics | Hardware | uConsole hardware probe | Desktop extension | +| Packet diagnostics | Logs | Linux packet log runtime | Desktop extension | + +## Capability honesty + +Desktop UI availability does not imply physical hardware availability. Radio +tools display `Available`, `Simulated`, `Degraded`, `Unsupported`, or `Error` +from the shared capability contract. This is especially important for the +current Linux Walkie and SSTV development backends, which must not be presented +as real audio/radio hardware. + +## Verification boundary + +The shared uConsole shell and UX pack are covered by the Windows CMake smoke +suite. Modified GTK translation units are syntax-checked in a Linux compiler +environment. The 1280 x 720 LVGL shell is also built and exercised through the +real SDL3 presenter, including renderer readback screenshots for Overview, +Chat, Map, GPS, Radio tools, and Extensions. + +See the [uConsole SDL visual review](uconsole-sdl-visual-review.md) for the +screenshots, acceptance checklist, and reproduction command. SDL validates the +shared composition and interaction model; final GTK pixel metrics still require +a GTK4-capable target because GTK remains a separate widget renderer. diff --git a/docs/specs/uconsole-sdl-visual-review.md b/docs/specs/uconsole-sdl-visual-review.md new file mode 100644 index 00000000..5def4d8f --- /dev/null +++ b/docs/specs/uconsole-sdl-visual-review.md @@ -0,0 +1,198 @@ +# uConsole SDL visual review + +This review captures the uConsole 1280 x 720 desktop interaction model through +the repository's real SDL3 presenter. The screenshots are not hand-built +mockups: the capture executable boots the LVGL uConsole shell, navigates it +through its normal keyboard input path, renders each frame into an SDL3 +renderer, and reads the final pixels back with `SDL_RenderReadPixels`. + +Capture date: 2026-07-23 + +SDL version: 3.4.10 + +Canvas and window size: 1280 x 720 + +Capture frame: 45 after shell startup +Runtime mode: isolated simulator demo data + +## Visual acceptance criteria + +| Area | Review result | +| --- | --- | +| Navigation | The 168 px rail keeps all 13 workspaces grouped and reachable, but `\` collapses it when the active task benefits from the full width. Direct workspace keys avoid repeated rail traversal. | +| Desktop density | The 44 px title bar, 36 px page header, 30 px status bar, inline metrics, single-line activity rows, and content-height utility panels prevent sparse data from being stretched into large empty cards. | +| Style consistency | The shell now uses the embedded theme's cream page and card surfaces, amber title bar and active states, brown typography, gold borders, and existing blue/green/warning status colors. The desktop layout changes density and information architecture, not product identity. | +| Desktop adaptation | Wide screens use task-focused master/detail panes and contextual summaries. Hardware state, contacts, map controls, and download details are no longer duplicated on every page. | +| Keyboard operation | The bottom bar always exposes the current workspace, runtime summary, and relevant keys. `F1` or `H` opens the complete shortcut reference; global letter shortcuts are suspended while typing in chat. | +| Map behavior | The map page shows cached tiles, background worker count, retry state, total cache size, and a direct cache-directory action. Automatic map download remains a desktop-specific capability. | +| Protocol boundary | UI capture uses Trail Mate's native runtime and demo data. It does not introduce or invoke `meshtasticd`. | +| Capability honesty | The overview and radio pages distinguish detected, unavailable, and simulated capabilities rather than presenting every desktop backend as physical hardware. | + +## Embedded palette parity + +The capture deliberately uses the same core values as the ESP32 UI rather than +introducing a separate Linux gray/green theme: + +| Token | Value | Desktop use | +| --- | --- | --- | +| Page background | `#FFF3DF` | Application canvas | +| Surface / alternate surface | `#FFF7E9` / `#FFF0D3` | Cards and navigation rail | +| Dashboard panel | `#FAF0D8` | Lists, message area, and workbench rows | +| Accent / dark accent | `#EBA341` / `#C98118` | Title bar, selected navigation, and attention states | +| Border / separator | `#D9B06A` / `#E8D2AB` | Desktop pane and card boundaries | +| Primary / muted text | `#3A2A1A` / `#6A5646` | Main copy and secondary metadata | +| Status green / blue | `#5BAF4A` / `#2F6FD6` | Healthy and informational states | +| Map / sweep background | `#F6E7C8` / `#F2E4C8` | Map tiles, sky plot, and Energy Sweep | + +The canonical shared values come from +`modules/ui_shared/include/ui/ui_theme.h`; the dashboard-specific panel, +accent, and text values are aligned with +`modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp`. The uConsole +shell keeps those colors in a named local palette so its desktop-only widgets +do not scatter unrelated literals through the renderer. + +## Information architecture + +The revised shell follows a task-first rule: one dominant work surface, one +optional contextual inspector, and no unrelated persistent detail rail. + +- Overview shows only four inline counters and recent conversation activity. + Full capability diagnostics belong to Hardware; full node lists belong to + Contacts. +- Map uses the canvas as the dominant surface. Layer and navigation actions are + a compact toolbar, while automatic-download health is a small contextual + inspector. +- GPS keeps the sky plot dominant and adapts the Pager GNSS visual language to + a desktop split view: the plot remains spatial, while the right side becomes + a compact satellite status table. +- Radio tools and Extensions size themselves to their actual contents instead + of extending bordered panels to the bottom of the screen. +- Chat intentionally retains a large transcript viewport because its empty + space becomes message history during normal operation. + +## Overview + +The overview is now an activity index, not a page that duplicates every +subsystem. Counters are inline, conversations use desktop-style single-line +rows, and the card stops at its real content height. + +![uConsole SDL overview](images/uconsole-sdl/overview.png) + +## Chat workspace + +The chat workspace uses a conversation master pane and a message detail pane. +The layout preserves the existing Trail Mate chat model and native protocol +stack while making keyboard and pointer navigation efficient. + +![uConsole SDL chat workspace](images/uconsole-sdl/chat.png) + +## Offline map and automatic download + +The map workspace reserves the largest area for field context. Layer controls +move into a short toolbar above the map; automatic-download health remains +visible as a narrow contextual inspector on the right. Downloading is global +and continues independently of the selected page. + +![uConsole SDL offline map workspace](images/uconsole-sdl/map.png) + +## GPS and satellite sky plot + +GPS diagnostics reuse the Pager sky-plot rules instead of treating the chart as +a decorative radar. Azimuth rotates clockwise from north, elevation projects +from the horizon toward the zenith, circle fill identifies the constellation, +the outline identifies signal state, and an explicit `USE` tag marks satellites +used in the fix. Ring captions preserve the Pager `90° / 60° / 30° / Horizon` +convention. The wide screen adds a persistent +`ID / SYS / ELEV / SNR / USE` table without weakening those embedded +conventions. Fix quality and the map transition remain visible without opening +a modal. + +![uConsole SDL GPS sky plot](images/uconsole-sdl/gps-sky-plot.png) + +## Radio tools workbench + +Energy Sweep, SSTV, and Walkie retain independent runtimes and controls but are +grouped into one desktop workbench. This reduces navigation depth without +removing any ESP32 capability. + +![uConsole SDL radio tools](images/uconsole-sdl/radio-tools.png) + +## Extensions + +Extensions use a catalog/detail split appropriate for package browsing on a +desktop. Package compatibility, storage, and install state remain explicit. + +![uConsole SDL extensions](images/uconsole-sdl/extensions.png) + +## Bottom status bar and keyboard shortcuts + +The 30 px bottom bar is persistent on every workspace: + +- Left: current workspace and whether navigation is hidden. +- Center: active mesh protocol, local node, and unread count. +- Right: the most relevant keys for the active workspace. + +The global shortcut set follows the keyboard-first Pager/T-Deck interaction +model: + +| Key | Action | +| --- | --- | +| `Up`, `Down`, `Tab` | Move focus | +| `Enter`, `Right` | Open or activate the focused control | +| `Esc` | Return to Overview; closes shortcut help first | +| `[` / `]` | Previous / next workspace | +| `\` | Collapse / restore the navigation rail | +| `F1` or `H` | Toggle shortcut help | +| `O`, `C`, `M`, `N`, `G`, `T` | Overview, Chat, Map, Contacts, GPS, Team | +| `K`, `R`, `W`, `D`, `E`, `L`, `S` | Tracker, Radio tools, Hardware, Data & maps, Extensions, Logs, Settings | + +Letter shortcuts are not consumed while the chat composer owns focus, so normal +message entry remains intact. + +![uConsole SDL shortcut help](images/uconsole-sdl/shortcut-help.png) + +## Collapsible navigation + +The map capture below sends normal keyboard navigation to open Map and then +sends `\` through the same input path used by the application. The rail is +removed from layout, the map expands immediately, and the bottom-left status +explicitly reports that navigation is hidden. + +![uConsole SDL map with collapsed navigation](images/uconsole-sdl/sidebar-collapsed.png) + +## Reproduce the capture + +The capture target is opt-in and does not change the normal GTK package build. +Provide an SDL3 CMake package, configure the target, build it, and run the +capture executable from the repository root: + +```sh +cmake -S apps/linux_uconsole_gtk -B build/uconsole-sdl \ + -DTRAIL_MATE_UCONSOLE_BUILD_REAL_GTK_UI=OFF \ + -DTRAIL_MATE_UCONSOLE_BUILD_SDL_SCREENSHOT_CAPTURE=ON \ + -DBUILD_TESTING=OFF \ + -DCMAKE_PREFIX_PATH=/path/to/sdl3 + +cmake --build build/uconsole-sdl \ + --target trailmate_uconsole_sdl_screenshot_capture + +SDL_VIDEODRIVER=dummy \ +SDL_RENDER_DRIVER=software \ +build/uconsole-sdl/trailmate_uconsole_sdl_screenshot_capture \ + docs/specs/images/uconsole-sdl +``` + +The tool captures `overview`, `chat`, `map`, `gps-sky-plot`, `radio-tools`, +`extensions`, `sidebar-collapsed`, and `shortcut-help`. The final two captures +exercise actual shortcut input rather than forcing widget visibility. Runtime +state is stored under `.codex-build/uconsole-sdl-runtime` so visual verification +does not read or overwrite the user's normal Trail Mate settings. + +## Verification boundary + +These images validate the LVGL/fbdev/SDL shell's composition, density, keyboard +navigation, and shared uConsole interaction model at the target resolution. +GTK4 remains a separate widget renderer, so its exact font metrics and native +widget geometry still require a GTK4-capable target check. The SDL review is +therefore a real executable visual gate for the shared design, not a claim that +SDL pixels are identical to GTK pixels. diff --git a/modules/ui_lvgl_ux_packs/src/packs/uconsole_desktop_ux_pack.cpp b/modules/ui_lvgl_ux_packs/src/packs/uconsole_desktop_ux_pack.cpp index bae2956e..cd096102 100644 --- a/modules/ui_lvgl_ux_packs/src/packs/uconsole_desktop_ux_pack.cpp +++ b/modules/ui_lvgl_ux_packs/src/packs/uconsole_desktop_ux_pack.cpp @@ -28,15 +28,15 @@ const UxFeatureSet& UConsoleDesktopUxPack::features() const { static const UxFeatureSet features{ true, - false, true, true, true, true, true, - false, - false, - false, + true, + true, + true, + true, }; return features; } @@ -46,10 +46,15 @@ void UConsoleDesktopUxPack::buildScreens(ScreenRegistry& out) const out.clear(); (void)out.add({ScreenId::Dashboard, "Dashboard", true}); (void)out.add({ScreenId::Chat, "Chat", true}); + (void)out.add({ScreenId::Contacts, "Contacts", true}); (void)out.add({ScreenId::Map, "Map", true}); (void)out.add({ScreenId::Gps, "GPS", true}); (void)out.add({ScreenId::Team, "Team", true}); (void)out.add({ScreenId::Tracker, "Tracker", true}); + (void)out.add({ScreenId::EnergySweep, "Energy Sweep", true}); + (void)out.add({ScreenId::WalkieTalkie, "Walkie", true}); + (void)out.add({ScreenId::Sstv, "SSTV", true}); + (void)out.add({ScreenId::Extensions, "Extensions", true}); (void)out.add({ScreenId::Settings, "Settings", true}); } diff --git a/modules/ui_lvgl_ux_packs/tests/test_uconsole_desktop_ux_pack.cpp b/modules/ui_lvgl_ux_packs/tests/test_uconsole_desktop_ux_pack.cpp index ea18d9ed..312dc592 100644 --- a/modules/ui_lvgl_ux_packs/tests/test_uconsole_desktop_ux_pack.cpp +++ b/modules/ui_lvgl_ux_packs/tests/test_uconsole_desktop_ux_pack.cpp @@ -13,14 +13,21 @@ int main() assert(pack.profile().map_mode == ui_lvgl_ux::MapMode::Desktop); assert(pack.profile().chat_mode == ui_lvgl_ux::ChatMode::Full); assert(pack.features().chat); - assert(!pack.features().contacts); - assert(!pack.features().walkie); + assert(pack.features().contacts); + assert(pack.features().walkie); + assert(pack.features().sstv); + assert(pack.features().extensions); ui_lvgl_ux::ScreenRegistry screens; pack.buildScreens(screens); - assert(screens.size() == 7); + assert(screens.size() == 12); assert(screens.items()[0].id == ui_lvgl_ux::ScreenId::Dashboard); assert(screens.items()[1].id == ui_lvgl_ux::ScreenId::Chat); - assert(screens.items()[6].id == ui_lvgl_ux::ScreenId::Settings); + assert(screens.items()[2].id == ui_lvgl_ux::ScreenId::Contacts); + assert(screens.items()[7].id == ui_lvgl_ux::ScreenId::EnergySweep); + assert(screens.items()[8].id == ui_lvgl_ux::ScreenId::WalkieTalkie); + assert(screens.items()[9].id == ui_lvgl_ux::ScreenId::Sstv); + assert(screens.items()[10].id == ui_lvgl_ux::ScreenId::Extensions); + assert(screens.items()[11].id == ui_lvgl_ux::ScreenId::Settings); return 0; } diff --git a/platform/linux/common/src/app/linux_app_services.cpp b/platform/linux/common/src/app/linux_app_services.cpp index 1abbbfbd..25b360d3 100644 --- a/platform/linux/common/src/app/linux_app_services.cpp +++ b/platform/linux/common/src/app/linux_app_services.cpp @@ -1876,6 +1876,12 @@ void LinuxAppServices::applyPositionConfig() config_.external_nmea_sentence_mask); platform::ui::gps::set_motion_idle_timeout(config_.motion_config.idle_timeout_ms); platform::ui::gps::set_motion_sensor_id(config_.motion_config.sensor_id); + platform::ui::tracker::set_interval_seconds( + std::max(1U, config_.map_track_interval)); + platform::ui::tracker::set_format( + static_cast( + std::min(config_.map_track_format, 2U))); + platform::ui::tracker::set_auto_recording(config_.map_track_enabled); (void)platform::ui::gps::diagnostics(); } diff --git a/platform/linux/common/src/platform/ui/lora_runtime.cpp b/platform/linux/common/src/platform/ui/lora_runtime.cpp index 33f49d99..d8a215c4 100644 --- a/platform/linux/common/src/platform/ui/lora_runtime.cpp +++ b/platform/linux/common/src/platform/ui/lora_runtime.cpp @@ -46,6 +46,8 @@ Clock::time_point s_started_at = Clock::now(); float s_freq_mhz = 0.0f; ReceiveConfig s_config{}; bool s_real_driver = false; +bool s_restore_config_valid = false; +::platform::linux_runtime::Sx126xLoRaConfig s_restore_config{}; std::string s_capability_message{}; bool env_flag_or_default(const char* name, bool fallback) @@ -177,6 +179,11 @@ bool acquire() std::lock_guard lock(s_mutex); auto& radio = ::platform::linux_runtime::Sx126xRadio::instance(); s_real_driver = radio.acquire(); + s_restore_config_valid = s_real_driver; + if (s_restore_config_valid) + { + s_restore_config = radio.appliedLoRaConfig(); + } s_acquired = s_real_driver || simulated_lora_enabled(); s_started_at = Clock::now(); return s_acquired; @@ -234,9 +241,15 @@ void release() s_configured = false; if (s_real_driver) { + if (s_restore_config_valid) + { + (void)::platform::linux_runtime::Sx126xRadio::instance() + .configureLoRa(s_restore_config); + } ::platform::linux_runtime::Sx126xRadio::instance().release(); } s_real_driver = false; + s_restore_config_valid = false; s_freq_mhz = 0.0f; } diff --git a/platform/linux/common/src/platform/ui/tracker_runtime.cpp b/platform/linux/common/src/platform/ui/tracker_runtime.cpp index 939e0181..cba896d7 100644 --- a/platform/linux/common/src/platform/ui/tracker_runtime.cpp +++ b/platform/linux/common/src/platform/ui/tracker_runtime.cpp @@ -24,6 +24,7 @@ namespace bool s_recording = false; std::string s_current_path{}; bool s_auto_recording = false; +bool s_started_automatically = false; uint32_t s_interval_seconds = 60; bool s_distance_only = false; Format s_format = Format::GPX; @@ -273,6 +274,7 @@ void stop_recording() } } s_recording = false; + s_started_automatically = false; s_current_path.clear(); s_last_sample_ms = 0; s_has_last_point = false; @@ -280,6 +282,11 @@ void stop_recording() void poll() { + if (s_auto_recording && !s_recording) + { + s_started_automatically = start_recording(); + } + const uint32_t now_ms = sys::millis_now(); if (!should_sample_now(now_ms)) { @@ -347,7 +354,14 @@ const char* track_dir() return s_track_dir_cache.c_str(); } -void set_auto_recording(bool enabled) { s_auto_recording = enabled; } +void set_auto_recording(bool enabled) +{ + s_auto_recording = enabled; + if (!enabled && s_started_automatically) + { + stop_recording(); + } +} void set_interval_seconds(uint32_t seconds) { s_interval_seconds = seconds; } void set_distance_only(bool enabled) { s_distance_only = enabled; } void set_format(Format format) { s_format = format; } diff --git a/platform/linux/uconsole/src/uconsole_desktop_shell.cpp b/platform/linux/uconsole/src/uconsole_desktop_shell.cpp index c41aa664..06d65e4c 100644 --- a/platform/linux/uconsole/src/uconsole_desktop_shell.cpp +++ b/platform/linux/uconsole/src/uconsole_desktop_shell.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include #include @@ -34,15 +36,59 @@ constexpr int kMetricCount = 4; constexpr int kCapabilityRows = 5; constexpr int kChatConversationRows = 7; constexpr int kChatMessageRows = 7; +constexpr std::uint32_t kLvglFunctionKeyF1 = 0x110001U; -constexpr std::array kNavLabels{ +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", - "Contacts", "Map", + "Contacts", + "GPS & sky plot", "Team", - "Data", - "Diagnostics", + "Tracker", + "Radio tools", + "Hardware", + "Data & maps", + "Extensions", + "Logs", "Settings", }; @@ -52,11 +98,16 @@ enum class Section : std::uint8_t { Overview = 0, Chat, - Contacts, Map, + Contacts, + Gps, Team, + Tracker, + RadioTools, + Hardware, Data, - Diagnostics, + Extensions, + Logs, Settings, }; @@ -100,6 +151,8 @@ struct QueuedKeyEvent 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: @@ -143,7 +196,9 @@ void resetBox(lv_obj_t* obj) lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); } -void applyPanel(lv_obj_t* obj, std::uint32_t bg, std::uint32_t border = 0xD3D9D2) +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); @@ -201,15 +256,25 @@ void setLabel(lv_obj_t* label, const char* text) case Section::Chat: return "Chat workspace"; case Section::Contacts: - return "Contacts workspace"; + return "Contacts & nodes"; case Section::Map: - return "Map workspace"; + return "Offline map workspace"; + case Section::Gps: + return "GPS & sky plot"; case Section::Team: - return "Team workspace"; + 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 workspace"; - case Section::Diagnostics: - return "Diagnostics workspace"; + return "Data & offline maps"; + case Section::Extensions: + return "Extensions"; + case Section::Logs: + return "Packet and runtime logs"; case Section::Settings: return "Settings workspace"; } @@ -225,15 +290,25 @@ void setLabel(lv_obj_t* label, const char* text) case Section::Chat: return "Conversation list and message activity preview."; case Section::Contacts: - return "Known nodes, nearby peers and trust status preview."; + return "Saved identities, nearby peers, trust and node actions."; case Section::Map: - return "Map canvas slot reserved for Linux package workflows."; + 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 "Roster, pairing and activity slot for team operations."; + 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 "Import/export and local storage jobs will surface here."; - case Section::Diagnostics: - return "Runtime logs, capability state and hardware checks."; + 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."; } @@ -326,6 +401,10 @@ class UConsoleDesktopShell { 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}); @@ -457,17 +536,178 @@ class UConsoleDesktopShell 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; + } + + 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; + } + } + + 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; + } + shortcuts_visible_ = !shortcuts_visible_; + if (shortcuts_visible_) + { + lv_obj_clear_flag(shortcut_overlay_, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(shortcut_overlay_); + } + else + { + lv_obj_add_flag(shortcut_overlay_, LV_OBJ_FLAG_HIDDEN); + } + } + 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() @@ -475,7 +715,7 @@ class UConsoleDesktopShell lv_obj_t* root = lv_scr_act(); lv_obj_clean(root); resetBox(root); - lv_obj_set_style_bg_color(root, color(0xECEFEA), 0); + 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); @@ -488,8 +728,8 @@ class UConsoleDesktopShell applyTransparent(body); lv_obj_set_width(body, LV_PCT(100)); lv_obj_set_flex_grow(body, 1); - lv_obj_set_style_pad_all(body, 14, 0); - lv_obj_set_style_pad_column(body, 14, 0); + 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); @@ -497,6 +737,8 @@ class UConsoleDesktopShell buildSidebar(body); buildWorkspace(body); buildStatusPanel(body); + buildBottomBar(root); + buildShortcutOverlay(root); selectSection(Section::Overview); } @@ -506,34 +748,183 @@ class UConsoleDesktopShell lv_obj_t* bar = lv_obj_create(root); resetBox(bar); lv_obj_set_width(bar, LV_PCT(100)); - lv_obj_set_height(bar, 58); - lv_obj_set_style_bg_color(bar, color(0x202426), 0); + 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, 18, 0); - lv_obj_set_style_pad_right(bar, 18, 0); - lv_obj_set_style_pad_top(bar, 8, 0); - lv_obj_set_style_pad_bottom(bar, 8, 0); - lv_obj_set_style_pad_column(bar, 12, 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_SIZE_CONTENT); + 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_COLUMN); + 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_, 190); + + footer_status_label_ = + createLabel(bar, "Starting services...", &lv_font_montserrat_12, + embedded_palette::kTextMuted); + lv_obj_set_flex_grow(footer_status_label_, 1); + lv_obj_set_style_text_align(footer_status_label_, + LV_TEXT_ALIGN_CENTER, 0); + + footer_shortcuts_label_ = + createLabel(bar, "", &lv_font_montserrat_12, + embedded_palette::kText); + lv_obj_set_width(footer_shortcuts_label_, 470); + lv_obj_set_style_text_align(footer_shortcuts_label_, + LV_TEXT_ALIGN_RIGHT, 0); + } + + 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, 690, 430); + lv_obj_set_style_pad_all(card, 18, 0); + lv_obj_set_style_pad_row(card, 14, 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(title_wrap, "Trail Mate uConsole", &lv_font_montserrat_20, - 0xF7F8F4); - createLabel(title_wrap, "Linux desktop-class target", - &lv_font_montserrat_12, 0xAAB2AF); + createLabel(card, "uConsole keyboard shortcuts", + &lv_font_montserrat_20, embedded_palette::kText); + createLabel(card, + "Global shortcuts are disabled while typing in the chat " + "composer.", + &lv_font_montserrat_12, embedded_palette::kTextMuted); - top_mesh_label_ = createChip(bar, "Mesh: -", 0x2F5D62, 0xDDF8F0); - top_node_label_ = createChip(bar, "Node: -", 0x584A1F, 0xFFF1BF); - top_unread_label_ = createChip(bar, "Unread: 0", 0x4F3B4D, 0xF8DDF2); + 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); + lv_obj_t* navigation_help = createLabel( + navigation, + "Up / Down / Tab Move focus\n" + "Enter / Right Open or activate\n" + "Esc Return to overview\n" + "[ / ] Previous / next workspace\n" + "\\ Collapse navigation\n" + "F1 / H Toggle this help", + &lv_font_montserrat_14, embedded_palette::kText, + LV_LABEL_LONG_WRAP); + lv_obj_set_width(navigation_help, LV_PCT(100)); + lv_obj_set_style_text_line_space(navigation_help, 9, 0); + + 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); + createLabel(workspaces, "DIRECT WORKSPACES", &lv_font_montserrat_12, + embedded_palette::kTextDim); + lv_obj_t* workspace_help = createLabel( + workspaces, + "O Overview C Chat\n" + "M Map N Contacts\n" + "G GPS T Team\n" + "K Tracker R Radio tools\n" + "W Hardware D Data & maps\n" + "E Extensions L Logs\n" + "S Settings", + &lv_font_montserrat_14, embedded_palette::kText, + LV_LABEL_LONG_WRAP); + lv_obj_set_width(workspace_help, LV_PCT(100)); + lv_obj_set_style_text_line_space(workspace_help, 9, 0); + + createLabel(card, + "Shortcut model follows the keyboard-first Pager/T-Deck " + "interaction style.", + &lv_font_montserrat_12, embedded_palette::kTextMuted); + + lv_obj_add_flag(shortcut_overlay_, LV_OBJ_FLAG_HIDDEN); + } + + [[nodiscard]] const char* shortcutHint() const noexcept + { + switch (active_section_) + { + case Section::Chat: + return "\\ Nav | Tab Focus | Enter Open/Send | F1 Help"; + case Section::Map: + return "\\ Nav | M Map | [ ] Workspace | F1 Help"; + case Section::Overview: + return "\\ Nav | C Chat | M Map | [ ] Page | F1 Help"; + default: + return "\\ Nav | Enter Open | [ ] Workspace | F1 Help"; + } + } + + 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); + setLabel(footer_shortcuts_label_, shortcutHint()); } lv_obj_t* createChip(lv_obj_t* parent, @@ -543,12 +934,12 @@ class UConsoleDesktopShell { lv_obj_t* chip = lv_obj_create(parent); resetBox(chip); - lv_obj_set_size(chip, LV_SIZE_CONTENT, 32); + 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, 10, 0); - lv_obj_set_style_pad_right(chip, 10, 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); @@ -558,19 +949,36 @@ class UConsoleDesktopShell void buildSidebar(lv_obj_t* parent) { sidebar_ = lv_obj_create(parent); - applyPanel(sidebar_, 0x252A29, 0x252A29); - lv_obj_set_width(sidebar_, 220); + 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_, 12, 0); - lv_obj_set_style_pad_row(sidebar_, 8, 0); + 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_, "Workspace", &lv_font_montserrat_14, 0xE8EDE8); + 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], @@ -585,18 +993,19 @@ class UConsoleDesktopShell lv_obj_t* button = lv_btn_create(parent); resetBox(button); lv_obj_set_width(button, LV_PCT(100)); - lv_obj_set_height(button, 42); - lv_obj_set_style_radius(button, 6, 0); - lv_obj_set_style_pad_left(button, 10, 0); - lv_obj_set_style_pad_right(button, 10, 0); - lv_obj_set_style_bg_color(button, color(0x303635), 0); + 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_14, 0xD7DED9); + 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); @@ -609,7 +1018,7 @@ class UConsoleDesktopShell applyTransparent(workspace); lv_obj_set_height(workspace, LV_PCT(100)); lv_obj_set_flex_grow(workspace, 1); - lv_obj_set_style_pad_row(workspace, 12, 0); + 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); @@ -617,21 +1026,24 @@ class UConsoleDesktopShell lv_obj_t* header = lv_obj_create(workspace); applyTransparent(header); lv_obj_set_width(header, LV_PCT(100)); - lv_obj_set_height(header, 54); - lv_obj_set_flex_flow(header, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(header, LV_FLEX_ALIGN_CENTER, - LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - workspace_title_ = createLabel(header, sectionTitle(active_section_), - &lv_font_montserrat_20, 0x1F2523); + 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, 0x61706A); + &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_, 96); - lv_obj_set_style_pad_column(metrics_panel_, 10, 0); + 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); @@ -642,36 +1054,41 @@ class UConsoleDesktopShell createMetric(metrics_panel_, 3, "Nearby"); conversation_panel_ = lv_obj_create(workspace); - applyPanel(conversation_panel_, 0xFFFFFF); + applyPanel(conversation_panel_, embedded_palette::kSurface); lv_obj_set_width(conversation_panel_, LV_PCT(100)); - lv_obj_set_flex_grow(conversation_panel_, 1); - lv_obj_set_style_pad_row(conversation_panel_, 8, 0); + 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, 0x24302C); + &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, 0xFFFFFF); + applyPanel(box, embedded_palette::kSurface); lv_obj_set_height(box, LV_PCT(100)); lv_obj_set_flex_grow(box, 1); - lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(box, LV_FLEX_ALIGN_CENTER, - LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - createLabel(box, title, &lv_font_montserrat_12, 0x66716E); + 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_24, 0x1E2B27); + createLabel(box, "0", &lv_font_montserrat_20, + embedded_palette::kText); } void buildConversationRow(int index) @@ -679,47 +1096,51 @@ class UConsoleDesktopShell lv_obj_t* row = lv_obj_create(conversation_panel_); applyTransparent(row); lv_obj_set_width(row, LV_PCT(100)); - lv_obj_set_height(row, 62); - lv_obj_set_style_pad_top(row, 4, 0); - lv_obj_set_style_pad_bottom(row, 4, 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); + 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, 0x22302B); - lv_obj_set_width(conversation_title_labels_[index], LV_PCT(100)); + 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, 0x596760); - lv_obj_set_width(conversation_preview_labels_[index], LV_PCT(100)); + 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, 0x8A6A20); - lv_obj_set_width(conversation_meta_labels_[index], LV_PCT(100)); + 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_, 0xFFFFFF); + 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_, 12, 0); + 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, 310); + lv_obj_set_width(conversation_list, 280); lv_obj_set_height(conversation_list, LV_PCT(100)); - lv_obj_set_style_pad_row(conversation_list, 8, 0); + 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, - 0x24302C); + embedded_palette::kText); for (int index = 0; index < kChatConversationRows; ++index) { buildChatConversationRow(conversation_list, index); @@ -729,7 +1150,7 @@ class UConsoleDesktopShell applyTransparent(thread); lv_obj_set_height(thread, LV_PCT(100)); lv_obj_set_flex_grow(thread, 1); - lv_obj_set_style_pad_row(thread, 10, 0); + 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); @@ -737,29 +1158,32 @@ class UConsoleDesktopShell 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, 48); - lv_obj_set_flex_flow(thread_header, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(thread_header, LV_FLEX_ALIGN_CENTER, - LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - chat_title_label_ = - createLabel(thread_header, "-", &lv_font_montserrat_16, 0x1F2523); - chat_meta_label_ = - createLabel(thread_header, "-", &lv_font_montserrat_12, 0x61706A); - lv_obj_set_width(chat_title_label_, LV_PCT(100)); - lv_obj_set_width(chat_meta_label_, 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_, 0xF8FAF6); + 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_, 8, 0); + 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, 0x65716B); + 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) { @@ -769,14 +1193,14 @@ class UConsoleDesktopShell 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, 72); - lv_obj_set_style_pad_column(input_row, 10, 0); + 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_, 54); + 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"); @@ -786,25 +1210,782 @@ class UConsoleDesktopShell chat_send_button_ = lv_btn_create(input_row); resetBox(chat_send_button_); - lv_obj_set_size(chat_send_button_, 92, 54); + 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(0x2F5D62), 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, - 0xF7F8F4); + 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, 0x66716E); + 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; + } + + void 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)); + } + + 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); + } + + void buildMapPreview() + { + 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, 2, 0); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + 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}; + for (int row_index = 0; row_index < 3; ++row_index) + { + lv_obj_t* row = lv_obj_create(grid); + resetBox(row); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, 0); + lv_obj_set_flex_grow(row, 1); + lv_obj_set_style_pad_column(row, 2, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + for (int column = 0; column < 3; ++column) + { + const int tile_index = (row_index * 3) + column; + lv_obj_t* tile = lv_obj_create(row); + resetBox(tile); + lv_obj_set_width(tile, 0); + lv_obj_set_height(tile, LV_PCT(100)); + lv_obj_set_flex_grow(tile, 1); + lv_obj_set_style_bg_color( + tile, color(tile_colors[tile_index]), 0); + lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); + lv_obj_set_style_border_color( + tile, color(embedded_palette::kBorder), 0); + lv_obj_set_style_border_width(tile, 1, 0); + if (tile_index == 4) + { + lv_obj_t* marker = + addActionPill(tile, "YOU", + embedded_palette::kStatusGreen, + embedded_palette::kWhite); + lv_obj_center(marker); + } + else if (tile_index == 2 || tile_index == 6) + { + lv_obj_t* marker = addActionPill( + tile, tile_index == 2 ? "SCOUT" : "BASE", + embedded_palette::kSoftAmber, + embedded_palette::kText); + lv_obj_center(marker); + } + } + } + createLabel(map, + "31.2304 N / 121.4737 E / zoom 14 / Terrain", + &lv_font_montserrat_12, embedded_palette::kTextMuted); + + lv_obj_t* download = createPreviewPanel( + desktop_page_panel_, "Map downloads", 220, + embedded_palette::kSurface); + addPreviewRow(download, "Cache", "2,418 tiles / 386 MB", + embedded_palette::kStatusGreen); + addPreviewRow(download, "Downloading", "4 workers / visible area", + embedded_palette::kAccentDark); + addPreviewRow(download, "Retries", "2 queued / backoff active", + embedded_palette::kWarn); + addActionPill(download, "Open cache directory"); + } + + void buildGpsPreview() + { + 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; + }; + constexpr std::array satellites{{ + {12, "GPS", 332.0F, 67.0F, 42, true, 0xE3B11F, 0x3E7D3E, + embedded_palette::kHeaderText}, + {31, "GPS", 296.0F, 52.0F, 39, true, 0xE3B11F, 0x3E7D3E, + embedded_palette::kHeaderText}, + {7, "BDS", 8.0F, 61.0F, 38, true, 0xB94A2C, 0x3E7D3E, + embedded_palette::kWhite}, + {4, "GAL", 158.0F, 48.0F, 36, true, 0x3E7D3E, 0x3E7D3E, + embedded_palette::kWhite}, + {11, "GAL", 132.0F, 35.0F, 33, true, 0x3E7D3E, 0x8FBF4D, + embedded_palette::kWhite}, + {19, "BDS", 226.0F, 29.0F, 30, true, 0xB94A2C, 0x8FBF4D, + embedded_palette::kWhite}, + {22, "GPS", 82.0F, 23.0F, 28, false, 0xE3B11F, 0xB94A2C, + embedded_palette::kHeaderText}, + {5, "GPS", 278.0F, 16.0F, 18, false, 0xE3B11F, 0x6E6E6E, + embedded_palette::kHeaderText}, + }}; + for (const auto& sat : satellites) + { + 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); + + createLabel(sky, "North up / live 1 Hz / altitude 18.4 m", + &lv_font_montserrat_12, embedded_palette::kTextWarm); + + lv_obj_t* receiver = createPreviewPanel( + desktop_page_panel_, "Satellite status", 330, + embedded_palette::kSurface, true); + + 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, "USE 6/8 HDOP 0.8 FIX 3D", + &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 < satellites.size(); + ++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); + } + } + + lv_obj_t* receiver_meta = + createLabel(receiver, "Live / 31.2304, 121.4737 / GPS UTC", + &lv_font_montserrat_10, + embedded_palette::kTextMuted); + lv_obj_set_width(receiver_meta, LV_PCT(100)); + addActionPill(receiver, "Open map"); + } + + 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, @@ -815,7 +1996,8 @@ class UConsoleDesktopShell 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(0xF3F6F2), 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, @@ -828,11 +2010,14 @@ class UConsoleDesktopShell chat_conversation_buttons_[index] = button; chat_conversation_title_labels_[index] = - createLabel(button, "-", &lv_font_montserrat_14, 0x22302B); + createLabel(button, "-", &lv_font_montserrat_14, + embedded_palette::kText); chat_conversation_preview_labels_[index] = - createLabel(button, "", &lv_font_montserrat_12, 0x596760); + createLabel(button, "", &lv_font_montserrat_12, + embedded_palette::kTextMuted); chat_conversation_meta_labels_[index] = - createLabel(button, "", &lv_font_montserrat_12, 0x8A6A20); + 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)); @@ -841,7 +2026,7 @@ class UConsoleDesktopShell void buildChatMessageRow(int index) { lv_obj_t* row = lv_obj_create(chat_messages_panel_); - applyPanel(row, 0xFFFFFF); + 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); @@ -851,12 +2036,14 @@ class UConsoleDesktopShell chat_message_rows_[index] = row; chat_message_sender_labels_[index] = - createLabel(row, "-", &lv_font_montserrat_12, 0x34413D); + createLabel(row, "-", &lv_font_montserrat_12, + embedded_palette::kTextWarm); chat_message_text_labels_[index] = - createLabel(row, "", &lv_font_montserrat_14, 0x17211E, - LV_LABEL_LONG_WRAP); + createLabel(row, "", &lv_font_montserrat_14, + embedded_palette::kText, LV_LABEL_LONG_WRAP); chat_message_meta_labels_[index] = - createLabel(row, "", &lv_font_montserrat_12, 0x66716E); + 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)); @@ -864,37 +2051,39 @@ class UConsoleDesktopShell void buildStatusPanel(lv_obj_t* parent) { - lv_obj_t* panel = lv_obj_create(parent); - applyPanel(panel, 0xFFFFFF); - lv_obj_set_width(panel, 330); - lv_obj_set_height(panel, LV_PCT(100)); - lv_obj_set_style_pad_row(panel, 12, 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); + 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(panel, "Runtime and capabilities", &lv_font_montserrat_16, - 0x24302C); + createLabel(status_panel_, "Runtime and capabilities", + &lv_font_montserrat_16, embedded_palette::kText); for (int index = 0; index < kCapabilityRows; ++index) { capability_labels_[index] = - createLabel(panel, "-", &lv_font_montserrat_12, 0x4F5C57, + 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(panel); + 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(0xD9DEDA), 0); + lv_obj_set_style_bg_color(divider, + color(embedded_palette::kSeparator), 0); lv_obj_set_style_bg_opa(divider, LV_OPA_COVER, 0); - createLabel(panel, "Contacts and nearby nodes", &lv_font_montserrat_16, - 0x24302C); + createLabel(status_panel_, "Contacts and nearby nodes", + &lv_font_montserrat_16, embedded_palette::kText); for (int index = 0; index < kContactRows; ++index) { - buildContactRow(panel, index); + buildContactRow(status_panel_, index); } } @@ -910,10 +2099,12 @@ class UConsoleDesktopShell contact_rows_[index] = row; contact_name_labels_[index] = - createLabel(row, "-", &lv_font_montserrat_14, 0x22302B); + 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, 0x66716E); + createLabel(row, "", &lv_font_montserrat_12, + embedded_palette::kTextMuted); lv_obj_set_width(contact_meta_labels_[index], LV_PCT(100)); } @@ -925,32 +2116,41 @@ class UConsoleDesktopShell 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 ? 0xD7E8DF : 0x303635), 0); + 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(0x7EA48F), 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 ? 0x15251F : 0xD7DED9), 0); + 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 (chat_active) + 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 (chat_active) + if (!overview_active) lv_obj_add_flag(conversation_panel_, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(conversation_panel_, LV_OBJ_FLAG_HIDDEN); @@ -962,6 +2162,17 @@ class UConsoleDesktopShell 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) @@ -978,6 +2189,10 @@ class UConsoleDesktopShell 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)); @@ -999,7 +2214,9 @@ class UConsoleDesktopShell setLabel(conversation_meta_labels_[index], item.meta); lv_obj_set_style_text_color( conversation_meta_labels_[index], - color(item.unread > 0 ? 0xA06316 : 0x78827D), 0); + color(item.unread > 0 ? embedded_palette::kAccentDark + : embedded_palette::kTextDim), + 0); } else { @@ -1075,14 +2292,19 @@ class UConsoleDesktopShell setLabel(chat_conversation_meta_labels_[index], item.meta); lv_obj_set_style_bg_color( chat_conversation_buttons_[index], - color(item.active ? 0xD7E8DF : 0xF3F6F2), 0); + 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(0x7EA48F), 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 ? 0xA06316 : 0x66716E), 0); + color(item.unread > 0 ? embedded_palette::kAccentDark + : embedded_palette::kTextMuted), + 0); } const bool has_messages = !snapshot.messages.empty(); @@ -1111,12 +2333,16 @@ class UConsoleDesktopShell setLabel(chat_message_meta_labels_[index], item.meta); lv_obj_set_style_bg_color( chat_message_rows_[index], - color(item.failed ? 0xFFF0EE - : (item.outgoing ? 0xEEF8F3 : 0xFFFFFF)), + 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 ? 0xA23B30 : 0x66716E), 0); + color(item.failed ? embedded_palette::kError + : embedded_palette::kTextMuted), + 0); } } @@ -1124,6 +2350,8 @@ class UConsoleDesktopShell UConsoleDashboardModel dashboard_model_; UConsoleChatWorkspaceModel chat_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_{}; @@ -1135,9 +2363,15 @@ class UConsoleDesktopShell 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_label_ = 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;