From 7756d0229ea8d5d13b6d14cf97fa599ca041763e Mon Sep 17 00:00:00 2001 From: dianjixz <18637716021@163.com> Date: Wed, 1 Jul 2026 16:43:52 +0800 Subject: [PATCH] Add ZClaw app project --- SDK | 2 +- ext_components/cp0_lvgl/SConstruct | 2 + .../APPLaunch/main/ui/page_app/ui_app_st.hpp | 2 +- projects/APPLaunch/setup.ini | 2 +- .../share/images/zclaw_avatar_16.png | Bin 0 -> 830 bytes .../share/images/zclaw_send_button_18.png | Bin 0 -> 909 bytes .../share/images/zclaw_sparkles_10.png | Bin 0 -> 392 bytes projects/ZClaw/SConstruct | 79 ++ .../assets/share/images/zclaw_avatar_16.png | Bin 0 -> 830 bytes .../share/images/zclaw_send_button_18.png | Bin 0 -> 909 bytes .../assets/share/images/zclaw_sparkles_10.png | Bin 0 -> 392 bytes projects/ZClaw/config_defaults.mk | 82 ++ .../linux_x86_cross_cp0_config_defaults.mk | 102 ++ .../ZClaw/linux_x86_sdl2_config_defaults.mk | 94 ++ .../ZClaw/mac_cross_cp0_config_defaults.mk | 102 ++ projects/ZClaw/main/Kconfig | 9 + projects/ZClaw/main/SConstruct | 50 + projects/ZClaw/main/include/main.h | 19 + projects/ZClaw/main/src/lvgl_main.cpp | 29 + projects/ZClaw/main/src/main.cpp | 5 + projects/ZClaw/main/ui/zclaw_app.cpp | 1164 +++++++++++++++++ 21 files changed, 1740 insertions(+), 3 deletions(-) create mode 100644 projects/ZClaw/APPLaunch/share/images/zclaw_avatar_16.png create mode 100644 projects/ZClaw/APPLaunch/share/images/zclaw_send_button_18.png create mode 100644 projects/ZClaw/APPLaunch/share/images/zclaw_sparkles_10.png create mode 100644 projects/ZClaw/SConstruct create mode 100644 projects/ZClaw/assets/share/images/zclaw_avatar_16.png create mode 100644 projects/ZClaw/assets/share/images/zclaw_send_button_18.png create mode 100644 projects/ZClaw/assets/share/images/zclaw_sparkles_10.png create mode 100644 projects/ZClaw/config_defaults.mk create mode 100644 projects/ZClaw/linux_x86_cross_cp0_config_defaults.mk create mode 100644 projects/ZClaw/linux_x86_sdl2_config_defaults.mk create mode 100644 projects/ZClaw/mac_cross_cp0_config_defaults.mk create mode 100644 projects/ZClaw/main/Kconfig create mode 100644 projects/ZClaw/main/SConstruct create mode 100644 projects/ZClaw/main/include/main.h create mode 100644 projects/ZClaw/main/src/lvgl_main.cpp create mode 100644 projects/ZClaw/main/src/main.cpp create mode 100644 projects/ZClaw/main/ui/zclaw_app.cpp diff --git a/SDK b/SDK index 1b124f48..3acba40d 160000 --- a/SDK +++ b/SDK @@ -1 +1 @@ -Subproject commit 1b124f486436f141a49aa74b8528be1b47ce0ca8 +Subproject commit 3acba40d3d1d2d25b389c1ba91b08b519872fed1 diff --git a/ext_components/cp0_lvgl/SConstruct b/ext_components/cp0_lvgl/SConstruct index 177445c0..af828457 100644 --- a/ext_components/cp0_lvgl/SConstruct +++ b/ext_components/cp0_lvgl/SConstruct @@ -24,6 +24,8 @@ if "CONFIG_CP0_LVGL_COMPONENT_ENABLED" in os.environ or "CONFIG_SIGSLOT_COMPONEN SRCS += Glob('src/*.c*') if 'CONFIG_V9_5_LV_USE_SDL' in os.environ: DEFINITIONS += ['-DHAL_PLATFORM_SDL=1'] + DEFINITIONS += pkg_config_cflags("sdl2") + REQUIREMENTS += pkg_config_ldflags("sdl2") SRCS += Glob('src/sdl/*.c*') else: SRCS += Glob('src/cp0/*.c*') diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_st.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_st.hpp index 6ceb33b4..61799725 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_st.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_st.hpp @@ -120,7 +120,7 @@ class UISTPage : public AppPage UISTPage() : AppPage() { - set_page_title("ST"); + set_page_title("CLI"); reset_terminal(); create_ui(); bind_events(); diff --git a/projects/APPLaunch/setup.ini b/projects/APPLaunch/setup.ini index 2659432c..7a46b7df 100644 --- a/projects/APPLaunch/setup.ini +++ b/projects/APPLaunch/setup.ini @@ -1,7 +1,7 @@ [ssh] local_file_path = dist remote_file_path = /home/pi/dist -remote_host = 192.168.28.114 +remote_host = 192.168.28.187 remote_port = 22 username = pi password = pi diff --git a/projects/ZClaw/APPLaunch/share/images/zclaw_avatar_16.png b/projects/ZClaw/APPLaunch/share/images/zclaw_avatar_16.png new file mode 100644 index 0000000000000000000000000000000000000000..b2df0f6592ff47ecc436c11c9b25c12f902c16d8 GIT binary patch literal 830 zcmV-E1Ht@>P)P>V zEG%@N6C?8w_67}YRS@iLF=koo>cY~}wjTgM1w`dP^8EbY9V;Ur8f)jOO7OV{0&zod z&SGs!sLP;)rQ6xsi7W4K_n;z+i)z-^His#rYe|xhZEbDVosP)%9nO>mto&Zn?FJPY zWNBt?d91vU1eQDY-Z>aI+FzAbUTMI2uY>@%ZU$aEkrGA1#Sc8gBSHv}q@qz|0xD1y zlg=3Ty2WZ0kSsIopLV1f8*$)JOqnBOwU{8o#;PDH?4FEh4;c)w;}Oe|V+q4Uh9ojPd#J(W zz6cY9EHmtya99-#U_^D@V%b0Av~!QBCPycbN&sFUso*f`FlND69 zaTE*xcXh4$<2Pk_xQJ$8+W@XS_>E$2q_8>j9A;w zQhdn2IQd!j!k2RxQB(xc;|G3gjNU(b29Z~ti5gX1qXa+^aX4eBLb(|T=ia=OoE|(O zD*rl&APVsA(e8un+a literal 0 HcmV?d00001 diff --git a/projects/ZClaw/APPLaunch/share/images/zclaw_send_button_18.png b/projects/ZClaw/APPLaunch/share/images/zclaw_send_button_18.png new file mode 100644 index 0000000000000000000000000000000000000000..4a10a0af3a35a9e184be14c416b4e65087d50081 GIT binary patch literal 909 zcmV;819JR{P)qP0zci&m>rMnMh=O3GiYtYp6#R*mn}tTuV!IF&ia%&wgbIpoT3rd6 zh16C{<3dodbtA1H4J|H;ElNcZ3Q`T4z}s30W-Cpb9Z-rI0=b(4~vr$yjUOJ1Dye zk!Uyun1HPT7!DfvW^NA)OBaZ-5qpEK#aMsngXenNwZeBA?gsMz7LK_gAz!x{BK&&s zJ9@=sL<33O5p_p*s)>)6X&^4>+UX%SSnU3V;UqT-13vE}wBNvY)gg)g|{wh2UQ zAaHHCgdzl0%s~*TtRzX8k?CpIi}Q;=HnPzNhs9DABt)zt&)Q%KPea57Wm)A8ihx_y z&PQUuxO(ip&o*q=efz}3;_lLlYZ~*IJXL65SoWx_Sk=)wwY!xuHe*p0SER~ba$L02 z@h~$pa;{9hr(C34%SU5!Hv==rR|Sf@i>Kt~d1EAV(xqW49((stsh#t8 zxAM^g>bfmxhOe~|0x{%}hA8rF*q!YzYpP;h%@ipl9uAP2WQd&kk)4U+`FlL%y(2KSCzQ*3E*#*y%tJqFsX-0Xn8Zl z5O1Oml>a!yFvNmLVG7WGWh+W{7*xAvDkbk1or^yXe)7HfO@h4J?$1V(*U)*F}eJwa9l9*6RDFGP)P>V zEG%@N6C?8w_67}YRS@iLF=koo>cY~}wjTgM1w`dP^8EbY9V;Ur8f)jOO7OV{0&zod z&SGs!sLP;)rQ6xsi7W4K_n;z+i)z-^His#rYe|xhZEbDVosP)%9nO>mto&Zn?FJPY zWNBt?d91vU1eQDY-Z>aI+FzAbUTMI2uY>@%ZU$aEkrGA1#Sc8gBSHv}q@qz|0xD1y zlg=3Ty2WZ0kSsIopLV1f8*$)JOqnBOwU{8o#;PDH?4FEh4;c)w;}Oe|V+q4Uh9ojPd#J(W zz6cY9EHmtya99-#U_^D@V%b0Av~!QBCPycbN&sFUso*f`FlND69 zaTE*xcXh4$<2Pk_xQJ$8+W@XS_>E$2q_8>j9A;w zQhdn2IQd!j!k2RxQB(xc;|G3gjNU(b29Z~ti5gX1qXa+^aX4eBLb(|T=ia=OoE|(O zD*rl&APVsA(e8un+a literal 0 HcmV?d00001 diff --git a/projects/ZClaw/assets/share/images/zclaw_send_button_18.png b/projects/ZClaw/assets/share/images/zclaw_send_button_18.png new file mode 100644 index 0000000000000000000000000000000000000000..4a10a0af3a35a9e184be14c416b4e65087d50081 GIT binary patch literal 909 zcmV;819JR{P)qP0zci&m>rMnMh=O3GiYtYp6#R*mn}tTuV!IF&ia%&wgbIpoT3rd6 zh16C{<3dodbtA1H4J|H;ElNcZ3Q`T4z}s30W-Cpb9Z-rI0=b(4~vr$yjUOJ1Dye zk!Uyun1HPT7!DfvW^NA)OBaZ-5qpEK#aMsngXenNwZeBA?gsMz7LK_gAz!x{BK&&s zJ9@=sL<33O5p_p*s)>)6X&^4>+UX%SSnU3V;UqT-13vE}wBNvY)gg)g|{wh2UQ zAaHHCgdzl0%s~*TtRzX8k?CpIi}Q;=HnPzNhs9DABt)zt&)Q%KPea57Wm)A8ihx_y z&PQUuxO(ip&o*q=efz}3;_lLlYZ~*IJXL65SoWx_Sk=)wwY!xuHe*p0SER~ba$L02 z@h~$pa;{9hr(C34%SU5!Hv==rR|Sf@i>Kt~d1EAV(xqW49((stsh#t8 zxAM^g>bfmxhOe~|0x{%}hA8rF*q!YzYpP;h%@ipl9uAP2WQd&kk)4U+`FlL%y(2KSCzQ*3E*#*y%tJqFsX-0Xn8Zl z5O1Oml>a!yFvNmLVG7WGWh+W{7*xAvDkbk1or^yXe)7HfO@h4J?$1V(*U)*F}eJwa9l9*6RDFG +#ifdef __cplusplus +int lvgl_main(void); +void ui_init(void) ; + + + + + + + + +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif diff --git a/projects/ZClaw/main/src/lvgl_main.cpp b/projects/ZClaw/main/src/lvgl_main.cpp new file mode 100644 index 00000000..8a2b4413 --- /dev/null +++ b/projects/ZClaw/main/src/lvgl_main.cpp @@ -0,0 +1,29 @@ +#include "lvgl/lvgl.h" +#include +#include +#include +#include "hal_lvgl_bsp.h" +#include "main.h" + +int lvgl_main(void) +{ + lv_init(); + cp0_lvgl_init(); + lv_display_t *disp = lv_display_get_default(); + if (disp == nullptr) { + fprintf(stderr, "HelloWorld: failed to create LVGL display\n"); + return 1; + } + + printf("HelloWorld: display %dx%d\n", + (int)lv_display_get_horizontal_resolution(disp), + (int)lv_display_get_vertical_resolution(disp)); + + ui_init(); + lv_obj_invalidate(lv_screen_active()); + + while (1) { + lv_timer_handler(); + usleep(10000); + } +} diff --git a/projects/ZClaw/main/src/main.cpp b/projects/ZClaw/main/src/main.cpp new file mode 100644 index 00000000..cb3a7efb --- /dev/null +++ b/projects/ZClaw/main/src/main.cpp @@ -0,0 +1,5 @@ +#include +int main(int argc, char *argv[]) +{ + return lvgl_main(); +} \ No newline at end of file diff --git a/projects/ZClaw/main/ui/zclaw_app.cpp b/projects/ZClaw/main/ui/zclaw_app.cpp new file mode 100644 index 00000000..3fd5f729 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_app.cpp @@ -0,0 +1,1164 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "main.h" +#include "lvgl/lvgl.h" +#include "hal_lvgl_bsp.h" +#include "cp0_lvgl_file.hpp" +#include "keyboard_input.h" + +#include + +#include +#include +#include +#include +#include + +namespace { + +inline const struct key_item *keyboard_item(lv_event_t *event) +{ + return static_cast(lv_event_get_param(event)); +} + +inline uint32_t keyboard_key(lv_event_t *event) +{ + const struct key_item *item = keyboard_item(event); + return item ? item->key_code : 0; +} + +class ZClawApp +{ + static constexpr lv_coord_t SCREEN_W = 320; + static constexpr lv_coord_t SCREEN_H = 170; + + static constexpr uint32_t COLOR_BG = 0x0F0F1A; + static constexpr uint32_t COLOR_BAR = 0x16162A; + static constexpr uint32_t COLOR_PANEL = 0x1E1E35; + static constexpr uint32_t COLOR_PANEL_LINE = 0x2A2A45; + static constexpr uint32_t COLOR_MUTED = 0xA1A1AA; + static constexpr uint32_t COLOR_DIM = 0x52525B; + static constexpr uint32_t COLOR_TEXT = 0xF4F4F5; + static constexpr uint32_t COLOR_ONLINE = 0x10B981; + static constexpr uint32_t COLOR_PURPLE = 0x8B5CF6; + static constexpr uint32_t COLOR_INDIGO = 0x6366F1; + static constexpr lv_coord_t USER_BUBBLE_MAX_W = 198; + static constexpr lv_coord_t USER_BUBBLE_MIN_W = 38; + static constexpr lv_coord_t USER_BUBBLE_PAD_X = 10; + static constexpr lv_coord_t USER_BUBBLE_PAD_Y = 6; + static constexpr lv_coord_t CHAT_ROW_W = 296; + static constexpr int SETTINGS_ROW_MAX = 5; + static constexpr const char *PROVIDERS_CONFIG_PATH = "~/.zeroclaw/zclaw_providers.tsv"; + + enum class SettingsView { + Main, + Providers, + ProviderDetail + }; + + enum class ProviderEditField { + None, + Alias, + Family, + Model, + Uri, + ApiKey + }; + + enum class InputMode { + Chat, + ProviderEdit + }; + + struct ProviderConfig { + std::string alias; + std::string family; + std::string model; + std::string uri; + std::string api_key; + }; + + lv_obj_t *screen_ = nullptr; + lv_obj_t *chat_container_ = nullptr; + lv_obj_t *scroll_track_ = nullptr; + lv_obj_t *scroll_thumb_ = nullptr; + lv_obj_t *user_bubble_ = nullptr; + lv_obj_t *user_label_ = nullptr; + lv_obj_t *reply_bubble_ = nullptr; + lv_obj_t *reply_label_ = nullptr; + lv_obj_t *input_bar_ = nullptr; + lv_obj_t *input_box_ = nullptr; + lv_obj_t *input_label_ = nullptr; + lv_obj_t *input_sparkle_ = nullptr; + lv_obj_t *send_button_ = nullptr; + lv_obj_t *input_dialog_ = nullptr; + lv_obj_t *input_textarea_ = nullptr; + lv_obj_t *settings_panel_ = nullptr; + lv_obj_t *settings_header_label_ = nullptr; + lv_obj_t *settings_hint_label_ = nullptr; + lv_obj_t *settings_rows_[SETTINGS_ROW_MAX] = {}; + lv_obj_t *settings_values_[SETTINGS_ROW_MAX] = {}; + lv_style_t textarea_cursor_style_; + bool textarea_cursor_style_inited_ = false; + bool settings_animating_ = false; + bool settings_closing_ = false; + SettingsView settings_view_ = SettingsView::Main; + int settings_selected_ = 0; + int settings_row_count_ = 0; + int provider_selected_ = 0; + int provider_scroll_ = 0; + int provider_detail_index_ = -1; + ProviderEditField provider_edit_field_ = ProviderEditField::None; + InputMode input_mode_ = InputMode::Chat; + std::vector providers_; + std::string avatar_path_; + std::string sparkles_path_; + std::string send_button_path_; + uint32_t reply_seed_ = 0; + +public: + ZClawApp() + { + root_screen_ = lv_screen_active(); + avatar_path_ = cp0_file_path("zclaw_avatar_16.png"); + sparkles_path_ = cp0_file_path("zclaw_sparkles_10.png"); + send_button_path_ = cp0_file_path("zclaw_send_button_18.png"); + load_providers(); + create_ui(); + event_handler_init(); + } + +private: + lv_obj_t *root_screen_ = nullptr; + static lv_obj_t *make_box(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, lv_coord_t w, lv_coord_t h, + uint32_t color, lv_coord_t radius = 0) + { + lv_obj_t *obj = lv_obj_create(parent); + lv_obj_set_pos(obj, x, y); + lv_obj_set_size(obj, w, h); + lv_obj_set_style_radius(obj, radius, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(obj, lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + return obj; + } + + static lv_obj_t *make_label(lv_obj_t *parent, const char *text, lv_coord_t x, lv_coord_t y, + lv_coord_t w, lv_coord_t h, const lv_font_t *font, uint32_t color, + lv_text_align_t align = LV_TEXT_ALIGN_LEFT) + { + lv_obj_t *label = lv_label_create(parent); + lv_label_set_text(label, text ? text : ""); + lv_obj_set_pos(label, x, y); + lv_obj_set_size(label, w, h); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(label, font, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_opa(label, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(label, align, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(label, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(label, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(label, LV_OBJ_FLAG_SCROLLABLE); + return label; + } + + static void apply_vertical_gradient(lv_obj_t *obj, uint32_t top, uint32_t bottom) + { + lv_obj_set_style_bg_color(obj, lv_color_hex(top), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_grad_color(obj, lv_color_hex(bottom), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_grad_dir(obj, LV_GRAD_DIR_VER, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + static lv_coord_t centered_y(lv_coord_t container_h, lv_coord_t item_h) + { + return (container_h - item_h) / 2; + } + + static std::string encode_field(const std::string &value) + { + std::string out; + for (char ch : value) { + if (ch == '\\') + out += "\\\\"; + else if (ch == '\t') + out += "\\t"; + else if (ch == '\n') + out += "\\n"; + else + out += ch; + } + return out; + } + + static std::string decode_field(const std::string &value) + { + std::string out; + for (size_t i = 0; i < value.size(); ++i) { + if (value[i] == '\\' && i + 1 < value.size()) { + const char next = value[++i]; + if (next == 't') + out += '\t'; + else if (next == 'n') + out += '\n'; + else + out += next; + } else { + out += value[i]; + } + } + return out; + } + + static std::vector split_tab_line(const std::string &line) + { + std::vector fields; + std::string current; + for (char ch : line) { + if (ch == '\t') { + fields.push_back(current); + current.clear(); + } else { + current += ch; + } + } + fields.push_back(current); + return fields; + } + + void load_providers() + { + providers_.clear(); + std::ifstream file(PROVIDERS_CONFIG_PATH); + std::string line; + while (std::getline(file, line)) { + if (line.empty()) + continue; + std::vector fields = split_tab_line(line); + if (fields.size() < 5) + continue; + ProviderConfig provider; + provider.alias = decode_field(fields[0]); + provider.family = decode_field(fields[1]); + provider.model = decode_field(fields[2]); + provider.uri = decode_field(fields[3]); + provider.api_key = decode_field(fields[4]); + providers_.push_back(provider); + } + + if (providers_.empty()) { + providers_.push_back({"openai", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", ""}); + providers_.push_back({"anthropic", "anthropic", "claude-sonnet-4", "https://api.anthropic.com", ""}); + } + } + + void save_providers() + { + std::ofstream file(PROVIDERS_CONFIG_PATH, std::ios::trunc); + if (!file) + return; + for (const ProviderConfig &provider : providers_) { + file << encode_field(provider.alias) << '\t' + << encode_field(provider.family) << '\t' + << encode_field(provider.model) << '\t' + << encode_field(provider.uri) << '\t' + << encode_field(provider.api_key) << '\n'; + } + } + + static const char *provider_field_name(ProviderEditField field) + { + switch (field) { + case ProviderEditField::Alias: + return "Alias"; + case ProviderEditField::Family: + return "Family"; + case ProviderEditField::Model: + return "Model"; + case ProviderEditField::Uri: + return "URI"; + case ProviderEditField::ApiKey: + return "API Key"; + case ProviderEditField::None: + default: + return ""; + } + } + + std::string &provider_field_value(ProviderConfig &provider, ProviderEditField field) + { + switch (field) { + case ProviderEditField::Alias: + return provider.alias; + case ProviderEditField::Family: + return provider.family; + case ProviderEditField::Model: + return provider.model; + case ProviderEditField::Uri: + return provider.uri; + case ProviderEditField::ApiKey: + case ProviderEditField::None: + default: + return provider.api_key; + } + } + + lv_obj_t *make_zclaw_avatar(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, lv_coord_t size) + { + lv_obj_t *avatar = lv_img_create(parent); + lv_img_set_src(avatar, avatar_path_.c_str()); + lv_obj_set_pos(avatar, x, y); + if (size != 16) { + lv_img_set_zoom(avatar, (uint16_t)((size * 256) / 16)); + } + lv_obj_clear_flag(avatar, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + return avatar; + } + + void create_ui() + { + screen_ = make_box(root_screen_, 0, 0, SCREEN_W, SCREEN_H, COLOR_BG); + lv_obj_move_foreground(screen_); + + create_top_bar(); + create_chat_area(); + create_input_bar(); + } + + void create_top_bar() + { + static constexpr lv_coord_t BAR_H = 20; + static constexpr lv_coord_t AVATAR_SIZE = 16; + static constexpr lv_coord_t STATUS_SIZE = 6; + static constexpr lv_coord_t DOT_SIZE = 2; + const lv_coord_t name_h = lv_font_get_line_height(&lv_font_montserrat_12); + const lv_coord_t status_h = lv_font_get_line_height(&lv_font_montserrat_10); + + lv_obj_t *bar = make_box(screen_, 0, 0, SCREEN_W, BAR_H, COLOR_BAR); + make_zclaw_avatar(bar, 12, centered_y(BAR_H, AVATAR_SIZE), AVATAR_SIZE); + + make_box(bar, 34, centered_y(BAR_H, STATUS_SIZE), STATUS_SIZE, STATUS_SIZE, COLOR_ONLINE, STATUS_SIZE / 2); + make_label(bar, "ZClaw", 47, centered_y(BAR_H, name_h), 42, name_h, &lv_font_montserrat_12, COLOR_TEXT); + make_label(bar, "Online", 89, centered_y(BAR_H, status_h), 48, status_h, &lv_font_montserrat_10, COLOR_ONLINE); + + const lv_coord_t ellipsis_y = centered_y(BAR_H, DOT_SIZE); + make_box(bar, 295, ellipsis_y, DOT_SIZE, DOT_SIZE, COLOR_MUTED, DOT_SIZE / 2); + make_box(bar, 300, ellipsis_y, DOT_SIZE, DOT_SIZE, COLOR_MUTED, DOT_SIZE / 2); + make_box(bar, 305, ellipsis_y, DOT_SIZE, DOT_SIZE, COLOR_MUTED, DOT_SIZE / 2); + } + + void create_chat_area() + { + make_box(screen_, 0, 20, SCREEN_W, 128, COLOR_BG); + + chat_container_ = lv_obj_create(screen_); + lv_obj_set_pos(chat_container_, 0, 20); + lv_obj_set_size(chat_container_, 312, 128); + lv_obj_set_style_bg_opa(chat_container_, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(chat_container_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(chat_container_, 12, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(chat_container_, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(chat_container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(chat_container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(chat_container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_flex_flow(chat_container_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(chat_container_, LV_DIR_VER); + lv_obj_add_flag(chat_container_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(chat_container_, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLL_ELASTIC | + LV_OBJ_FLAG_SCROLL_MOMENTUM | + LV_OBJ_FLAG_SCROLL_CHAIN)); + lv_obj_set_scrollbar_mode(chat_container_, LV_SCROLLBAR_MODE_OFF); + lv_obj_add_event_cb(chat_container_, ZClawApp::static_chat_scroll_handler, LV_EVENT_SCROLL, this); + + append_ai_message("Hello! I am ZClaw.\nReady to help with your device."); + + scroll_track_ = make_box(screen_, 312, 20, 3, 126, 0x33335A, 2); + scroll_thumb_ = make_box(screen_, 312, 28, 3, 38, 0x818CF8, 2); + apply_vertical_gradient(scroll_thumb_, 0xC4B5FD, 0x818CF8); + update_scrollbar(); + } + + void create_input_bar() + { + input_bar_ = make_box(screen_, 0, 148, SCREEN_W, 22, COLOR_BAR); + make_box(input_bar_, 0, 0, SCREEN_W, 1, COLOR_PANEL_LINE); + + input_box_ = make_box(input_bar_, 10, 3, 274, 16, COLOR_PANEL, 8); + input_sparkle_ = make_sparkle(input_box_, 8, 3); + input_label_ = make_label(input_box_, "Press Enter to ask", 26, 3, 180, 10, + &lv_font_montserrat_10, COLOR_DIM); + + send_button_ = make_send_button(input_bar_, 292, 2); + } + + lv_obj_t *make_settings_row(lv_obj_t *parent, int index, lv_coord_t y, const char *title, const char *value) + { + lv_obj_t *row = make_box(parent, 12, y, 296, 26, COLOR_PANEL, 8); + lv_obj_set_style_border_width(row, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN | LV_STATE_DEFAULT); + make_label(row, title, 10, 5, 160, 14, &lv_font_montserrat_10, COLOR_TEXT); + settings_values_[index] = make_label(row, value, 168, 5, 118, 14, &lv_font_montserrat_10, + COLOR_MUTED, LV_TEXT_ALIGN_RIGHT); + make_box(row, 8, 23, 280, 1, COLOR_PANEL_LINE); + settings_rows_[index] = row; + return row; + } + + void update_settings_selection() + { + for (int i = 0; i < SETTINGS_ROW_MAX; ++i) { + if (!settings_rows_[i]) + continue; + const bool selected = i == settings_selected_; + lv_obj_set_style_border_color(settings_rows_[i], + lv_color_hex(selected ? COLOR_PURPLE : COLOR_PANEL), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(settings_rows_[i], + lv_color_hex(selected ? 0x252542 : COLOR_PANEL), + LV_PART_MAIN | LV_STATE_DEFAULT); + if (settings_values_[i]) + lv_obj_set_style_text_color(settings_values_[i], + lv_color_hex(selected ? COLOR_TEXT : COLOR_MUTED), + LV_PART_MAIN | LV_STATE_DEFAULT); + } + } + + void clear_settings_rows() + { + for (auto *&row : settings_rows_) { + if (row) + lv_obj_del(row); + row = nullptr; + } + for (auto *&value : settings_values_) + value = nullptr; + settings_row_count_ = 0; + } + + void add_settings_row(const char *title, const char *value) + { + if (settings_row_count_ >= SETTINGS_ROW_MAX) + return; + const lv_coord_t y = 32 + settings_row_count_ * 28; + make_settings_row(settings_panel_, settings_row_count_, y, title, value); + ++settings_row_count_; + } + + void set_settings_header(const char *title, const char *hint) + { + if (settings_header_label_) + lv_label_set_text(settings_header_label_, title ? title : ""); + if (settings_hint_label_) + lv_label_set_text(settings_hint_label_, hint ? hint : ""); + } + + void render_settings_main() + { + settings_view_ = SettingsView::Main; + clear_settings_rows(); + set_settings_header("ZClaw Settings", "Tab / Esc"); + add_settings_row("Providers", "Manage"); + add_settings_row("Model", providers_.empty() ? "None" : providers_[0].model.c_str()); + add_settings_row("Status", "Online"); + add_settings_row("Input", "Enter"); + add_settings_row("Replies", "Random"); + if (settings_selected_ >= settings_row_count_) + settings_selected_ = settings_row_count_ - 1; + if (settings_selected_ < 0) + settings_selected_ = 0; + update_settings_selection(); + } + + void render_settings_providers() + { + settings_view_ = SettingsView::Providers; + clear_settings_rows(); + set_settings_header("Providers", "Enter / Esc"); + + if (provider_selected_ < 0) + provider_selected_ = 0; + const int total_rows = (int)providers_.size() + 1; + if (provider_selected_ >= total_rows) + provider_selected_ = total_rows - 1; + if (provider_selected_ < provider_scroll_) + provider_scroll_ = provider_selected_; + if (provider_selected_ >= provider_scroll_ + SETTINGS_ROW_MAX) + provider_scroll_ = provider_selected_ - SETTINGS_ROW_MAX + 1; + if (provider_scroll_ < 0) + provider_scroll_ = 0; + + for (int i = 0; i < SETTINGS_ROW_MAX && provider_scroll_ + i < total_rows; ++i) { + const int item = provider_scroll_ + i; + if (item == 0) { + add_settings_row("Add Provider", "+"); + } else { + const ProviderConfig &provider = providers_[item - 1]; + add_settings_row(provider.alias.c_str(), provider.model.c_str()); + } + } + + settings_selected_ = provider_selected_ - provider_scroll_; + update_settings_selection(); + } + + void render_provider_detail() + { + settings_view_ = SettingsView::ProviderDetail; + clear_settings_rows(); + set_settings_header("Provider", "Enter / Del"); + + if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) { + render_settings_providers(); + return; + } + + ProviderConfig &provider = providers_[provider_detail_index_]; + add_settings_row("Alias", provider.alias.c_str()); + add_settings_row("Family", provider.family.c_str()); + add_settings_row("Model", provider.model.c_str()); + add_settings_row("URI", provider.uri.c_str()); + add_settings_row("API Key", provider.api_key.empty() ? "(empty)" : "set"); + if (settings_selected_ >= settings_row_count_) + settings_selected_ = settings_row_count_ - 1; + if (settings_selected_ < 0) + settings_selected_ = 0; + update_settings_selection(); + } + + void create_settings_panel() + { + settings_panel_ = make_box(screen_, SCREEN_W, 0, SCREEN_W, SCREEN_H, COLOR_BG); + lv_obj_move_foreground(settings_panel_); + + static constexpr lv_coord_t BAR_H = 20; + lv_obj_t *bar = make_box(settings_panel_, 0, 0, SCREEN_W, BAR_H, COLOR_BAR); + settings_header_label_ = make_label(bar, "ZClaw Settings", 12, 4, 160, 12, + &lv_font_montserrat_12, COLOR_TEXT); + settings_hint_label_ = make_label(bar, "Tab / Esc", 214, 5, 94, 10, + &lv_font_montserrat_10, COLOR_DIM, LV_TEXT_ALIGN_RIGHT); + + settings_selected_ = 0; + render_settings_main(); + } + + static void static_settings_anim_done(lv_anim_t *a) + { + ZClawApp *self = static_cast(lv_anim_get_user_data(a)); + if (self) + self->settings_anim_done(); + } + + void settings_anim_done() + { + settings_animating_ = false; + if (settings_closing_) { + if (settings_panel_) { + lv_obj_del(settings_panel_); + settings_panel_ = nullptr; + settings_header_label_ = nullptr; + settings_hint_label_ = nullptr; + for (auto *&row : settings_rows_) + row = nullptr; + for (auto *&value : settings_values_) + value = nullptr; + } + settings_closing_ = false; + } + } + + void animate_settings_panel(lv_coord_t from_x, lv_coord_t to_x, bool closing) + { + if (!settings_panel_) + return; + + settings_animating_ = true; + settings_closing_ = closing; + lv_anim_del(settings_panel_, nullptr); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, settings_panel_); + lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_x); + lv_anim_set_values(&a, from_x, to_x); + lv_anim_set_time(&a, 200); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_set_completed_cb(&a, static_settings_anim_done); + lv_anim_set_user_data(&a, this); + lv_anim_start(&a); + } + + bool settings_panel_open() const + { + return settings_panel_ != nullptr; + } + + void open_settings_panel() + { + if (settings_panel_open() || settings_animating_) + return; + close_input_dialog(); + create_settings_panel(); + animate_settings_panel(SCREEN_W, 0, false); + } + + void close_settings_panel() + { + if (!settings_panel_open() || settings_animating_) + return; + animate_settings_panel(lv_obj_get_x(settings_panel_), SCREEN_W, true); + } + + void move_settings_selection(int delta) + { + if (!settings_panel_open()) + return; + settings_selected_ += delta; + if (settings_selected_ < 0) + settings_selected_ = 0; + if (settings_selected_ >= settings_row_count_) + settings_selected_ = settings_row_count_ - 1; + update_settings_selection(); + } + + lv_obj_t *make_sparkle(lv_obj_t *parent, lv_coord_t x, lv_coord_t y) + { + lv_obj_t *sparkles = lv_img_create(parent); + lv_img_set_src(sparkles, sparkles_path_.c_str()); + lv_obj_set_pos(sparkles, x, y); + lv_obj_clear_flag(sparkles, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + return sparkles; + } + + lv_obj_t *make_send_button(lv_obj_t *parent, lv_coord_t x, lv_coord_t y) + { + lv_obj_t *send = lv_img_create(parent); + lv_img_set_src(send, send_button_path_.c_str()); + lv_obj_set_pos(send, x, y); + lv_obj_clear_flag(send, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + return send; + } + + bool input_dialog_open() const + { + return input_dialog_ && input_textarea_; + } + + void close_input_dialog() + { + if (!input_dialog_) + return; + lv_obj_del(input_dialog_); + input_dialog_ = nullptr; + input_textarea_ = nullptr; + } + + void clear_obj(lv_obj_t *&obj) + { + if (!obj) + return; + lv_obj_del(obj); + obj = nullptr; + } + + lv_obj_t *make_chat_label(lv_obj_t *parent, const char *text, lv_coord_t x, lv_coord_t y, + lv_coord_t w, lv_coord_t h, uint32_t color, + lv_text_align_t align = LV_TEXT_ALIGN_LEFT) + { + lv_obj_t *label = make_label(parent, text, x, y, w, h, &lv_font_montserrat_10, color, align); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + return label; + } + + static lv_point_t measure_text_box(const char *text, const lv_font_t *font, lv_coord_t max_w) + { + lv_point_t size{}; + lv_text_get_size(&size, text ? text : "", font, 0, 0, max_w, LV_TEXT_FLAG_NONE); + return size; + } + + lv_obj_t *make_message_row(lv_coord_t h, lv_flex_align_t main_align) + { + lv_obj_t *row = lv_obj_create(chat_container_); + lv_obj_set_size(row, CHAT_ROW_W, h); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(row, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, main_align, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(row, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + return row; + } + + lv_obj_t *make_bubble(lv_obj_t *parent, lv_coord_t w, lv_coord_t h, uint32_t color, bool user) + { + lv_obj_t *bubble = make_box(parent, 0, 0, w, h, color, 10); + if (user) + apply_vertical_gradient(bubble, COLOR_PURPLE, COLOR_INDIGO); + return bubble; + } + + void scroll_chat_to_bottom() + { + if (chat_container_) { + lv_obj_scroll_to_y(chat_container_, LV_COORD_MAX, LV_ANIM_ON); + update_scrollbar(); + } + } + + void append_ai_message(const char *text) + { + static constexpr lv_coord_t bubble_w = 190; + static constexpr lv_coord_t text_w = 168; + static constexpr lv_coord_t pad_x = 10; + static constexpr lv_coord_t pad_y = 6; + const lv_point_t text_size = measure_text_box(text, &lv_font_montserrat_10, text_w); + lv_coord_t bubble_h = text_size.y + pad_y * 2; + if (bubble_h < 41) + bubble_h = 41; + + lv_obj_t *row = make_message_row(bubble_h, LV_FLEX_ALIGN_START); + make_zclaw_avatar(row, 0, 0, 16); + reply_bubble_ = make_bubble(row, bubble_w, bubble_h, COLOR_PANEL, false); + reply_label_ = make_chat_label(reply_bubble_, text, pad_x, pad_y, text_w, bubble_h - pad_y * 2, 0xFFFFFF); + scroll_chat_to_bottom(); + } + + void append_user_message(const std::string &text) + { + const lv_coord_t text_max_w = USER_BUBBLE_MAX_W - USER_BUBBLE_PAD_X * 2; + const lv_point_t text_size = measure_text_box(text.c_str(), &lv_font_montserrat_10, text_max_w); + lv_coord_t bubble_w = text_size.x + USER_BUBBLE_PAD_X * 2; + lv_coord_t bubble_h = text_size.y + USER_BUBBLE_PAD_Y * 2; + if (bubble_w < USER_BUBBLE_MIN_W) + bubble_w = USER_BUBBLE_MIN_W; + if (bubble_w > USER_BUBBLE_MAX_W) + bubble_w = USER_BUBBLE_MAX_W; + if (bubble_h < 27) + bubble_h = 27; + + lv_obj_t *row = make_message_row(bubble_h, LV_FLEX_ALIGN_END); + user_bubble_ = make_bubble(row, bubble_w, bubble_h, COLOR_INDIGO, true); + user_label_ = make_chat_label(user_bubble_, text.c_str(), USER_BUBBLE_PAD_X, USER_BUBBLE_PAD_Y, + bubble_w - USER_BUBBLE_PAD_X * 2, + bubble_h - USER_BUBBLE_PAD_Y * 2, + 0xFFFFFF, LV_TEXT_ALIGN_LEFT); + scroll_chat_to_bottom(); + } + + void open_input_dialog() + { + if (input_dialog_open()) + return; + + static constexpr lv_coord_t dialog_h = SCREEN_H * 2 / 3; + input_dialog_ = lv_msgbox_create(lv_layer_top()); + lv_obj_set_size(input_dialog_, 300, dialog_h); + lv_obj_align(input_dialog_, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_radius(input_dialog_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(input_dialog_, lv_color_hex(COLOR_BAR), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(input_dialog_, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(input_dialog_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(input_dialog_, lv_color_hex(COLOR_PANEL_LINE), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(input_dialog_, lv_color_hex(COLOR_TEXT), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(input_dialog_, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(input_dialog_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *content = lv_msgbox_get_content(input_dialog_); + lv_obj_set_style_pad_all(content, 5, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(content, LV_OBJ_FLAG_SCROLLABLE); + + input_textarea_ = lv_textarea_create(content); + lv_obj_set_size(input_textarea_, 290, dialog_h - 10); + lv_textarea_set_placeholder_text(input_textarea_, "Type your message..."); + lv_textarea_set_one_line(input_textarea_, false); + lv_textarea_set_cursor_click_pos(input_textarea_, false); + lv_obj_set_style_radius(input_textarea_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(input_textarea_, lv_color_hex(COLOR_PANEL), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(input_textarea_, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(input_textarea_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(input_textarea_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(input_textarea_, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(input_textarea_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + if (!textarea_cursor_style_inited_) { + lv_style_init(&textarea_cursor_style_); + lv_style_set_bg_opa(&textarea_cursor_style_, LV_OPA_TRANSP); + lv_style_set_border_color(&textarea_cursor_style_, lv_color_hex(COLOR_PURPLE)); + lv_style_set_border_side(&textarea_cursor_style_, LV_BORDER_SIDE_LEFT); + lv_style_set_border_width(&textarea_cursor_style_, 2); + lv_style_set_pad_hor(&textarea_cursor_style_, 1); + textarea_cursor_style_inited_ = true; + } + lv_obj_add_style(input_textarea_, &textarea_cursor_style_, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_state(input_textarea_, LV_STATE_FOCUSED); + lv_obj_send_event(input_textarea_, LV_EVENT_FOCUSED, nullptr); + lv_textarea_set_cursor_pos(input_textarea_, LV_TEXTAREA_CURSOR_LAST); + } + + void open_text_dialog(const char *placeholder, const char *initial_text, InputMode mode) + { + input_mode_ = mode; + open_input_dialog(); + if (!input_dialog_open()) + return; + lv_textarea_set_placeholder_text(input_textarea_, placeholder ? placeholder : ""); + lv_textarea_set_text(input_textarea_, initial_text ? initial_text : ""); + lv_textarea_set_cursor_pos(input_textarea_, LV_TEXTAREA_CURSOR_LAST); + } + + void show_sent_message(const std::string &text) + { + append_user_message(text); + } + + const char *random_reply() + { + static constexpr const char *replies[] = { + "Got it. Checking now.", + "I can help with that.", + "Done. What should I do next?", + "Received your request." + }; + ++reply_seed_; + return replies[(reply_seed_ + lv_tick_get()) % (sizeof(replies) / sizeof(replies[0]))]; + } + + void show_random_reply() + { + append_ai_message(random_reply()); + } + + void scroll_chat(int delta) + { + if (!chat_container_) + return; + + const int32_t top = lv_obj_get_scroll_top(chat_container_); + const int32_t bottom = lv_obj_get_scroll_bottom(chat_container_); + if ((delta > 0 && top <= 0) || (delta < 0 && bottom <= 0)) + return; + + int32_t applied = delta; + if (delta > top) + applied = top; + if (-delta > bottom) + applied = -bottom; + lv_obj_scroll_by(chat_container_, 0, applied, LV_ANIM_ON); + update_scrollbar(); + } + + void update_scrollbar() + { + if (!chat_container_ || !scroll_track_ || !scroll_thumb_) + return; + + static constexpr lv_coord_t track_y = 20; + static constexpr lv_coord_t track_h = 126; + const int32_t top = lv_obj_get_scroll_top(chat_container_); + const int32_t bottom = lv_obj_get_scroll_bottom(chat_container_); + const int32_t range = top + bottom; + if (range <= 0) { + lv_obj_set_pos(scroll_thumb_, 312, track_y); + lv_obj_set_size(scroll_thumb_, 3, track_h); + return; + } + + lv_coord_t thumb_h = (lv_coord_t)((int32_t)track_h * track_h / (track_h + range)); + if (thumb_h < 18) + thumb_h = 18; + if (thumb_h > track_h) + thumb_h = track_h; + + const lv_coord_t travel = track_h - thumb_h; + const lv_coord_t thumb_y = track_y + (lv_coord_t)((int32_t)travel * top / range); + lv_obj_set_pos(scroll_thumb_, 312, thumb_y); + lv_obj_set_size(scroll_thumb_, 3, thumb_h); + } + + static void static_chat_scroll_handler(lv_event_t *e) + { + ZClawApp *self = static_cast(lv_event_get_user_data(e)); + if (self) + self->update_scrollbar(); + } + + void add_provider() + { + const int next = (int)providers_.size() + 1; + ProviderConfig provider; + provider.alias = "provider" + std::to_string(next); + provider.family = "openai-compatible"; + provider.model = "model"; + provider.uri = "https://api.example.com/v1"; + providers_.push_back(provider); + save_providers(); + provider_selected_ = (int)providers_.size(); + provider_detail_index_ = (int)providers_.size() - 1; + settings_selected_ = 0; + render_provider_detail(); + } + + void delete_current_provider() + { + if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) + return; + providers_.erase(providers_.begin() + provider_detail_index_); + save_providers(); + provider_detail_index_ = -1; + if (provider_selected_ > (int)providers_.size()) + provider_selected_ = (int)providers_.size(); + render_settings_providers(); + } + + ProviderEditField selected_provider_field() const + { + switch (settings_selected_) { + case 0: + return ProviderEditField::Alias; + case 1: + return ProviderEditField::Family; + case 2: + return ProviderEditField::Model; + case 3: + return ProviderEditField::Uri; + case 4: + return ProviderEditField::ApiKey; + default: + return ProviderEditField::None; + } + } + + void edit_selected_provider_field() + { + if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) + return; + provider_edit_field_ = selected_provider_field(); + if (provider_edit_field_ == ProviderEditField::None) + return; + + ProviderConfig &provider = providers_[provider_detail_index_]; + const std::string &value = provider_field_value(provider, provider_edit_field_); + open_text_dialog(provider_field_name(provider_edit_field_), value.c_str(), InputMode::ProviderEdit); + } + + void apply_provider_edit(const std::string &value) + { + if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) + return; + if (provider_edit_field_ == ProviderEditField::None) + return; + + ProviderConfig &provider = providers_[provider_detail_index_]; + provider_field_value(provider, provider_edit_field_) = value; + provider_edit_field_ = ProviderEditField::None; + save_providers(); + render_provider_detail(); + } + + void activate_settings_selection() + { + if (!settings_panel_open()) + return; + + if (settings_view_ == SettingsView::Main) { + if (settings_selected_ == 0) { + provider_selected_ = 0; + provider_scroll_ = 0; + render_settings_providers(); + } + return; + } + + if (settings_view_ == SettingsView::Providers) { + const int item = provider_scroll_ + settings_selected_; + if (item == 0) { + add_provider(); + } else if (item - 1 < (int)providers_.size()) { + provider_detail_index_ = item - 1; + settings_selected_ = 0; + render_provider_detail(); + } + return; + } + + if (settings_view_ == SettingsView::ProviderDetail) + edit_selected_provider_field(); + } + + void settings_back() + { + if (settings_view_ == SettingsView::ProviderDetail) { + provider_detail_index_ = -1; + render_settings_providers(); + } else if (settings_view_ == SettingsView::Providers) { + settings_selected_ = 0; + render_settings_main(); + } else { + close_settings_panel(); + } + } + + void send_current_input() + { + if (!input_dialog_open()) + return; + + const char *text = lv_textarea_get_text(input_textarea_); + if (input_mode_ == InputMode::ProviderEdit) { + const std::string value = text ? text : ""; + close_input_dialog(); + apply_provider_edit(value); + input_mode_ = InputMode::Chat; + return; + } + + if (!text || !text[0]) { + close_input_dialog(); + return; + } + + const std::string sent = text; + close_input_dialog(); + show_sent_message(sent); + show_random_reply(); + } + + void append_input(const char *utf8) + { + if (!input_dialog_open() || !utf8 || !utf8[0]) + return; + lv_textarea_add_text(input_textarea_, utf8); + } + + void event_handler_init() + { + lv_obj_add_event_cb(root_screen_, ZClawApp::static_lvgl_handler, LV_EVENT_ALL, this); + } + + static void static_lvgl_handler(lv_event_t *e) + { + ZClawApp *self = static_cast(lv_event_get_user_data(e)); + if (self) + self->event_handler(e); + } + + void event_handler(lv_event_t *e) + { + if (lv_event_get_code(e) != static_cast(LV_EVENT_KEYBOARD)) + return; + + const struct key_item *item = keyboard_item(e); + if (!item) + return; + + const uint32_t key = keyboard_key(e); + const bool shift_down = (item->mods & KBD_MOD_SHIFT) != 0; + if (item->key_state == KBD_KEY_PRESSED || item->key_state == KBD_KEY_REPEATED) { + if (!input_dialog_open()) + return; + + switch (key) { + case KEY_ENTER: + if (shift_down) + lv_textarea_add_char(input_textarea_, '\n'); + break; + case KEY_BACKSPACE: + lv_textarea_delete_char(input_textarea_); + break; + case KEY_DELETE: + lv_textarea_delete_char_forward(input_textarea_); + break; + case KEY_LEFT: + lv_textarea_cursor_left(input_textarea_); + break; + case KEY_RIGHT: + lv_textarea_cursor_right(input_textarea_); + break; + case KEY_UP: + lv_textarea_cursor_up(input_textarea_); + break; + case KEY_DOWN: + lv_textarea_cursor_down(input_textarea_); + break; + default: + if (item->utf8[0] && (unsigned char)item->utf8[0] >= 0x20) + append_input(item->utf8); + break; + } + return; + } + + if (item->key_state != KBD_KEY_RELEASED) + return; + + if (input_dialog_open()) { + if (key == KEY_ESC) { + close_input_dialog(); + input_mode_ = InputMode::Chat; + return; + } + if (key == KEY_UP || key == KEY_DOWN || key == KEY_LEFT || key == KEY_RIGHT) + return; + if (key == KEY_ENTER) { + if (shift_down) + return; + send_current_input(); + return; + } + return; + } + + if (key == KEY_TAB) { + if (settings_panel_open()) + close_settings_panel(); + else + open_settings_panel(); + return; + } + if (settings_panel_open()) { + if (key == KEY_ESC) + settings_back(); + else if (key == KEY_BACKSPACE) + settings_back(); + else if (key == KEY_ENTER) + activate_settings_selection(); + else if (key == KEY_DELETE && settings_view_ == SettingsView::ProviderDetail) + delete_current_provider(); + else if (key == KEY_UP) + move_settings_selection(-1); + else if (key == KEY_DOWN) + move_settings_selection(1); + return; + } + if (key == KEY_ESC && input_dialog_open()) { + close_input_dialog(); + return; + } + if (key == KEY_ESC) + return; + else if (key == KEY_UP) + scroll_chat(24); + else if (key == KEY_DOWN) + scroll_chat(-24); + else if (key == KEY_ENTER) { + input_mode_ = InputMode::Chat; + open_input_dialog(); + } + } +}; + +ZClawApp *g_app = nullptr; + +} // namespace + +void ui_init() +{ + static ZClawApp app; + g_app = &app; +}