From 0acd37fa2c7c98aea7d3ba90bd2ce426633efef7 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Thu, 9 Jul 2026 23:53:43 +0800 Subject: [PATCH] Improve Nomad network directory UX --- .../ui/screens/network/network_page_shell.cpp | 83 +++++++++++++++++-- .../src/chat/infra/lxmf/lxmf_adapter.cpp | 50 +++++++++++ ...latform_ui_reticulum_directory_runtime.cpp | 58 +++++++++++++ .../ui/reticulum_directory_runtime.cpp | 58 +++++++++++++ 4 files changed, 242 insertions(+), 7 deletions(-) diff --git a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp index 2fd58c17..1bfc4ac4 100644 --- a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp @@ -129,6 +129,7 @@ struct NetworkPageState DirectoryMode directory_mode = DirectoryMode::Announces; bool immersive = false; bool directory_collapsed = false; + bool browser_collapsed = false; bool suppress_history = false; rtdir::Status announce_status{}; rtdir::Status address_status{}; @@ -564,6 +565,7 @@ void rebuild_focus_group(lv_obj_t* preferred = nullptr); void focus_browser_viewport(); void focus_directory_panel(); void apply_layout_state(); +lv_coord_t resolve_directory_width(); void open_network_help_modal(); void close_network_help_modal(); @@ -650,6 +652,10 @@ void add_viewport_links_to_group() void add_browser_focusables() { + if (g_state.browser_collapsed && !g_state.immersive) + { + return; + } if (!g_state.immersive) { add_visible_to_group(g_state.browser_back_btn); @@ -1550,6 +1556,7 @@ void apply_layout_state() { const bool immersive = g_state.immersive; const bool hide_directory = immersive || g_state.directory_collapsed; + const bool hide_browser = !immersive && g_state.browser_collapsed; if (g_state.header) { immersive ? lv_obj_add_flag(g_state.header, LV_OBJ_FLAG_HIDDEN) @@ -1559,6 +1566,14 @@ void apply_layout_state() { hide_directory ? lv_obj_add_flag(g_state.directory_panel, LV_OBJ_FLAG_HIDDEN) : lv_obj_clear_flag(g_state.directory_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_width(g_state.directory_panel, + hide_browser ? LV_PCT(100) : resolve_directory_width()); + lv_obj_set_flex_grow(g_state.directory_panel, hide_browser ? 1 : 0); + } + if (g_state.browser_panel) + { + hide_browser ? lv_obj_add_flag(g_state.browser_panel, LV_OBJ_FLAG_HIDDEN) + : lv_obj_clear_flag(g_state.browser_panel, LV_OBJ_FLAG_HIDDEN); } if (g_state.browser_toolbar) { @@ -1571,7 +1586,9 @@ void apply_layout_state() lv_obj_set_style_pad_right(g_state.content, immersive ? 0 : 2, LV_PART_MAIN); lv_obj_set_style_pad_top(g_state.content, immersive ? 0 : 2, LV_PART_MAIN); lv_obj_set_style_pad_bottom(g_state.content, immersive ? 0 : 2, LV_PART_MAIN); - lv_obj_set_style_pad_column(g_state.content, hide_directory ? 0 : 4, LV_PART_MAIN); + lv_obj_set_style_pad_column(g_state.content, + (hide_directory || hide_browser) ? 0 : 4, + LV_PART_MAIN); } if (g_state.browser_panel) { @@ -1583,12 +1600,12 @@ void apply_layout_state() { lv_obj_set_style_radius(g_state.viewport, immersive ? 0 : 8, LV_PART_MAIN); lv_obj_set_style_border_width(g_state.viewport, immersive ? 0 : 1, LV_PART_MAIN); - if (app_g) + if (app_g && !hide_browser) { lv_group_focus_obj(g_state.viewport); } } - rebuild_focus_group(g_state.viewport); + rebuild_focus_group(hide_browser ? g_state.directory_list : g_state.viewport); } void toggle_immersive() @@ -1603,7 +1620,12 @@ void toggle_directory_panel() { g_state.immersive = false; } - g_state.directory_collapsed = !g_state.directory_collapsed; + const bool hide_directory = !g_state.directory_collapsed; + g_state.directory_collapsed = hide_directory; + if (hide_directory) + { + g_state.browser_collapsed = false; + } apply_layout_state(); ::ui::feedback::show_notice(g_state.directory_collapsed ? safe_tr("Directory hidden") : safe_tr("Directory shown"), @@ -1618,6 +1640,32 @@ void toggle_directory_panel() } } +void toggle_browser_panel() +{ + if (g_state.immersive) + { + g_state.immersive = false; + } + const bool hide_browser = !g_state.browser_collapsed; + g_state.browser_collapsed = hide_browser; + if (hide_browser) + { + g_state.directory_collapsed = false; + } + apply_layout_state(); + ::ui::feedback::show_notice(g_state.browser_collapsed ? safe_tr("Browser hidden") + : safe_tr("Browser shown"), + 1200); + if (g_state.browser_collapsed) + { + focus_directory_panel(); + } + else + { + focus_browser_viewport(); + } +} + bool is_help_shortcut_key(uint32_t key) { return key == 'h' || key == 'H'; @@ -1628,6 +1676,11 @@ bool is_directory_toggle_shortcut_key(uint32_t key) return key == 'c' || key == 'C'; } +bool is_browser_toggle_shortcut_key(uint32_t key) +{ + return key == 'b' || key == 'B'; +} + void close_network_help_modal() { ::ui::components::shortcut_help_modal::close(g_state.help_modal); @@ -1645,10 +1698,11 @@ void open_network_help_modal() return; } - ::ui::components::shortcut_help_modal::Row rows[10] = {}; + ::ui::components::shortcut_help_modal::Row rows[11] = {}; std::size_t row_count = 0; rows[row_count++] = {"S", "/", "Search announces"}; rows[row_count++] = {"C", nullptr, "Show or hide left column"}; + rows[row_count++] = {"B", nullptr, "Show or hide browser"}; rows[row_count++] = {"I", nullptr, "Immersive browser"}; rows[row_count++] = {"Left", "Right", "Switch pane focus"}; rows[row_count++] = {"Enter", nullptr, "Open selected item"}; @@ -1691,15 +1745,30 @@ void page_shortcut_event_cb(lv_event_t* event) lv_event_stop_processing(event); return; } + if (is_browser_toggle_shortcut_key(key)) + { + toggle_browser_panel(); + lv_event_stop_processing(event); + return; + } if (key == LV_KEY_RIGHT && object_in_subtree(g_state.directory_panel, target)) { + if (g_state.browser_collapsed) + { + g_state.browser_collapsed = false; + apply_layout_state(); + } focus_browser_viewport(); lv_event_stop_processing(event); return; } - if (key == LV_KEY_LEFT && object_in_subtree(g_state.browser_panel, target) && - !g_state.directory_collapsed) + if (key == LV_KEY_LEFT && object_in_subtree(g_state.browser_panel, target)) { + if (g_state.directory_collapsed) + { + g_state.directory_collapsed = false; + apply_layout_state(); + } focus_directory_panel(); lv_event_stop_processing(event); return; diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp index 95c320e7..83e3eb81 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -464,6 +464,48 @@ void copyCString(char* out, size_t out_len, const char* in) out[out_len - 1] = '\0'; } +bool copyTextAppDataDisplayName(const uint8_t* data, + size_t len, + char* out, + size_t out_len) +{ + if (!data || len == 0 || len > 96 || !out || out_len == 0) + { + return false; + } + + size_t used = 0; + bool has_visible = false; + for (size_t index = 0; index < len; ++index) + { + uint8_t byte = data[index]; + if (byte == '\t' || byte == '\r' || byte == '\n') + { + byte = ' '; + } + else if (byte == 0 || byte < 0x20 || byte == 0x7F) + { + out[0] = '\0'; + return false; + } + + if (used + 1U < out_len) + { + out[used++] = static_cast(byte); + } + if (byte != ' ') + { + has_visible = true; + } + } + while (used != 0 && out[used - 1U] == ' ') + { + --used; + } + out[used] = '\0'; + return has_visible && used != 0; +} + uint32_t fnv1a32(const uint8_t* data, size_t len) { uint32_t hash = 2166136261UL; @@ -2237,6 +2279,14 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len (void)has_stamp_cost; (void)stamp_cost; } + else if (!(delivery_announce || propagation_announce || call_audio_announce) && + announce.app_data && announce.app_data_len != 0) + { + (void)copyTextAppDataDisplayName(announce.app_data, + announce.app_data_len, + announce_display_name, + sizeof(announce_display_name)); + } rtdir::AnnounceRecord directory_announce{}; directory_announce.valid = true; diff --git a/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp index f3ad557c..c2f329f9 100644 --- a/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp @@ -207,6 +207,58 @@ bool parse_hex(std::string_view text, uint8_t* out, std::size_t out_len) return true; } +bool copy_text_app_data_display_name_from_hex(std::string_view text, + char* out, + std::size_t out_len) +{ + if (!out || out_len == 0 || text.empty() || (text.size() % 2U) != 0) + { + return false; + } + const std::size_t byte_count = text.size() / 2U; + if (byte_count > 96) + { + return false; + } + + std::size_t used = 0; + bool has_visible = false; + for (std::size_t index = 0; index < byte_count; ++index) + { + const uint8_t hi = hex_nibble(text[index * 2U]); + const uint8_t lo = hex_nibble(text[(index * 2U) + 1U]); + if (hi == 0xFF || lo == 0xFF) + { + out[0] = '\0'; + return false; + } + uint8_t byte = static_cast((hi << 4U) | lo); + if (byte == '\t' || byte == '\r' || byte == '\n') + { + byte = ' '; + } + else if (byte == 0 || byte < 0x20 || byte == 0x7F) + { + out[0] = '\0'; + return false; + } + if (used + 1U < out_len) + { + out[used++] = static_cast(byte); + } + if (byte != ' ') + { + has_visible = true; + } + } + while (used != 0 && out[used - 1U] == ' ') + { + --used; + } + out[used] = '\0'; + return has_visible && used != 0; +} + std::string sanitize_field(const char* text) { std::string out = text ? text : ""; @@ -1063,6 +1115,12 @@ bool parse_announce_line(std::string_view line, AnnounceRecord& out) parsed.delivery = truthy(fields.values[9]); parsed.propagation = truthy(fields.values[10]); copy_view(parsed.display_name, sizeof(parsed.display_name), fields.values[11]); + if (parsed.display_name[0] == '\0' && fields.count >= 14) + { + (void)copy_text_app_data_display_name_from_hex(fields.values[13], + parsed.display_name, + sizeof(parsed.display_name)); + } parsed.valid = true; out = parsed; return true; diff --git a/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp b/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp index b5b9fb17..769c8167 100644 --- a/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp +++ b/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp @@ -151,6 +151,58 @@ bool parse_hex(std::string_view text, uint8_t* out, std::size_t out_len) return true; } +bool copy_text_app_data_display_name_from_hex(std::string_view text, + char* out, + std::size_t out_len) +{ + if (!out || out_len == 0 || text.empty() || (text.size() % 2U) != 0) + { + return false; + } + const std::size_t byte_count = text.size() / 2U; + if (byte_count > 96) + { + return false; + } + + std::size_t used = 0; + bool has_visible = false; + for (std::size_t index = 0; index < byte_count; ++index) + { + const uint8_t hi = hex_nibble(text[index * 2U]); + const uint8_t lo = hex_nibble(text[(index * 2U) + 1U]); + if (hi == 0xFF || lo == 0xFF) + { + out[0] = '\0'; + return false; + } + uint8_t byte = static_cast((hi << 4U) | lo); + if (byte == '\t' || byte == '\r' || byte == '\n') + { + byte = ' '; + } + else if (byte == 0 || byte < 0x20 || byte == 0x7F) + { + out[0] = '\0'; + return false; + } + if (used + 1U < out_len) + { + out[used++] = static_cast(byte); + } + if (byte != ' ') + { + has_visible = true; + } + } + while (used != 0 && out[used - 1U] == ' ') + { + --used; + } + out[used] = '\0'; + return has_visible && used != 0; +} + std::string sanitize_field(const char* text) { std::string out = text ? text : ""; @@ -502,6 +554,12 @@ bool parse_announce_line(std::string_view line, AnnounceRecord& out) parsed.delivery = truthy(fields.values[9]); parsed.propagation = truthy(fields.values[10]); copy_view(parsed.display_name, sizeof(parsed.display_name), fields.values[11]); + if (parsed.display_name[0] == '\0' && fields.count >= 14) + { + (void)copy_text_app_data_display_name_from_hex(fields.values[13], + parsed.display_name, + sizeof(parsed.display_name)); + } parsed.valid = true; out = parsed; return true;