From d0360625d6e1344eb87c80cd47f699717721f9ec Mon Sep 17 00:00:00 2001 From: Anna Antonenko Date: Wed, 24 Sep 2025 23:24:28 +0400 Subject: [PATCH] [FL-3925] JS views finished (#4155) * js: value destructuring and tests * js: temporary fix to see size impact * js_val: reduce code size 1 * i may be stupid. * test: js_value args * Revert "js: temporary fix to see size impact" This reverts commit f51d726dbafc4300d3552020de1c3b8f9ecd3ac1. * pvs: silence warnings * style: formatting * pvs: silence warnings? * pvs: silence warnings?? * js_value: redesign declaration types for less code * js: temporary fix to see size impact * style: formatting * pvs: fix helpful warnings * js_value: reduce .rodata size * pvs: fix helpful warning * js_value: reduce code size 1 * fix build error * style: format * Revert "js: temporary fix to see size impact" This reverts commit d6a46f01794132e882e03fd273dec24386a4f8ba. * style: format * js: move to new arg parser * style: format * feat: all js views done * js, toolbox: generalize string owning * toolbox: silence pvs warning --------- Co-authored-by: hedger Co-authored-by: hedger --- applications/services/gui/modules/popup.c | 5 +- applications/services/gui/modules/popup.h | 2 +- applications/system/js_app/application.fam | 48 +++ .../js_app/examples/apps/Scripts/gui.js | 120 +++++++- .../js_app/modules/js_gui/button_menu.c | 169 +++++++++++ .../js_app/modules/js_gui/button_panel.c | 274 ++++++++++++++++++ .../system/js_app/modules/js_gui/icon.c | 10 + .../system/js_app/modules/js_gui/js_gui.c | 21 +- .../system/js_app/modules/js_gui/js_gui.h | 5 + .../modules/js_gui/js_gui_api_table_i.h | 3 +- .../system/js_app/modules/js_gui/menu.c | 105 +++++++ .../js_app/modules/js_gui/number_input.c | 130 +++++++++ .../system/js_app/modules/js_gui/popup.c | 102 +++++++ .../system/js_app/modules/js_gui/submenu.c | 33 ++- .../system/js_app/modules/js_gui/vi_list.c | 163 +++++++++++ .../packages/fz-sdk/gui/button_menu.d.ts | 40 +++ .../packages/fz-sdk/gui/button_panel.d.ts | 49 ++++ .../js_app/packages/fz-sdk/gui/icon.d.ts | 5 +- .../js_app/packages/fz-sdk/gui/index.d.ts | 18 +- .../js_app/packages/fz-sdk/gui/menu.d.ts | 38 +++ .../packages/fz-sdk/gui/number_input.d.ts | 44 +++ .../js_app/packages/fz-sdk/gui/popup.d.ts | 43 +++ .../js_app/packages/fz-sdk/gui/submenu.d.ts | 5 +- .../js_app/packages/fz-sdk/gui/vi_list.d.ts | 38 +++ .../js_app/packages/fz-sdk/package.json | 2 +- lib/toolbox/SConscript | 1 + lib/toolbox/str_buffer.c | 18 ++ lib/toolbox/str_buffer.h | 47 +++ targets/f18/api_symbols.csv | 3 + targets/f7/api_symbols.csv | 3 + 30 files changed, 1496 insertions(+), 48 deletions(-) create mode 100644 applications/system/js_app/modules/js_gui/button_menu.c create mode 100644 applications/system/js_app/modules/js_gui/button_panel.c create mode 100644 applications/system/js_app/modules/js_gui/menu.c create mode 100644 applications/system/js_app/modules/js_gui/number_input.c create mode 100644 applications/system/js_app/modules/js_gui/popup.c create mode 100644 applications/system/js_app/modules/js_gui/vi_list.c create mode 100644 applications/system/js_app/packages/fz-sdk/gui/button_menu.d.ts create mode 100644 applications/system/js_app/packages/fz-sdk/gui/button_panel.d.ts create mode 100644 applications/system/js_app/packages/fz-sdk/gui/menu.d.ts create mode 100644 applications/system/js_app/packages/fz-sdk/gui/number_input.d.ts create mode 100644 applications/system/js_app/packages/fz-sdk/gui/popup.d.ts create mode 100644 applications/system/js_app/packages/fz-sdk/gui/vi_list.d.ts create mode 100644 lib/toolbox/str_buffer.c create mode 100644 lib/toolbox/str_buffer.h diff --git a/applications/services/gui/modules/popup.c b/applications/services/gui/modules/popup.c index 5c9c75e4a..6b5cbfdd0 100644 --- a/applications/services/gui/modules/popup.c +++ b/applications/services/gui/modules/popup.c @@ -93,13 +93,12 @@ static bool popup_view_input_callback(InputEvent* event, void* context) { void popup_start_timer(void* context) { Popup* popup = context; if(popup->timer_enabled) { - uint32_t timer_period = - popup->timer_period_in_ms / (1000.0f / furi_kernel_get_tick_frequency()); + uint32_t timer_period = furi_ms_to_ticks(popup->timer_period_in_ms); if(timer_period == 0) timer_period = 1; if(furi_timer_start(popup->timer, timer_period) != FuriStatusOk) { furi_crash(); - }; + } } } diff --git a/applications/services/gui/modules/popup.h b/applications/services/gui/modules/popup.h index a3e8dc773..d694dc45b 100644 --- a/applications/services/gui/modules/popup.h +++ b/applications/services/gui/modules/popup.h @@ -41,7 +41,7 @@ void popup_free(Popup* popup); */ View* popup_get_view(Popup* popup); -/** Set popup header text +/** Set popup timeout callback * * @param popup Popup instance * @param callback PopupCallback diff --git a/applications/system/js_app/application.fam b/applications/system/js_app/application.fam index 843ab5543..43e2cbeff 100644 --- a/applications/system/js_app/application.fam +++ b/applications/system/js_app/application.fam @@ -78,6 +78,54 @@ App( sources=["modules/js_gui/text_input.c"], ) +App( + appid="js_gui__number_input", + apptype=FlipperAppType.PLUGIN, + entry_point="js_view_number_input_ep", + requires=["js_app"], + sources=["modules/js_gui/number_input.c"], +) + +App( + appid="js_gui__button_panel", + apptype=FlipperAppType.PLUGIN, + entry_point="js_view_button_panel_ep", + requires=["js_app"], + sources=["modules/js_gui/button_panel.c"], +) + +App( + appid="js_gui__popup", + apptype=FlipperAppType.PLUGIN, + entry_point="js_view_popup_ep", + requires=["js_app"], + sources=["modules/js_gui/popup.c"], +) + +App( + appid="js_gui__button_menu", + apptype=FlipperAppType.PLUGIN, + entry_point="js_view_button_menu_ep", + requires=["js_app"], + sources=["modules/js_gui/button_menu.c"], +) + +App( + appid="js_gui__menu", + apptype=FlipperAppType.PLUGIN, + entry_point="js_view_menu_ep", + requires=["js_app"], + sources=["modules/js_gui/menu.c"], +) + +App( + appid="js_gui__vi_list", + apptype=FlipperAppType.PLUGIN, + entry_point="js_view_vi_list_ep", + requires=["js_app"], + sources=["modules/js_gui/vi_list.c"], +) + App( appid="js_gui__byte_input", apptype=FlipperAppType.PLUGIN, diff --git a/applications/system/js_app/examples/apps/Scripts/gui.js b/applications/system/js_app/examples/apps/Scripts/gui.js index bc569cee0..3a179fc9f 100644 --- a/applications/system/js_app/examples/apps/Scripts/gui.js +++ b/applications/system/js_app/examples/apps/Scripts/gui.js @@ -9,6 +9,12 @@ let byteInputView = require("gui/byte_input"); let textBoxView = require("gui/text_box"); let dialogView = require("gui/dialog"); let filePicker = require("gui/file_picker"); +let buttonMenuView = require("gui/button_menu"); +let buttonPanelView = require("gui/button_panel"); +let menuView = require("gui/menu"); +let numberInputView = require("gui/number_input"); +let popupView = require("gui/popup"); +let viListView = require("gui/vi_list"); let widget = require("gui/widget"); let icon = require("gui/icon"); let flipper = require("flipper"); @@ -27,6 +33,11 @@ let stopwatchWidgetElements = [ { element: "button", button: "right", text: "Back" }, ]; +// icons for the button panel +let offIcons = [icon.getBuiltin("off_19x20"), icon.getBuiltin("off_hover_19x20")]; +let powerIcons = [icon.getBuiltin("power_19x20"), icon.getBuiltin("power_hover_19x20")]; +let settingsIcon = icon.getBuiltin("Settings_14"); + // declare view instances let views = { loading: loadingView.make(), @@ -48,19 +59,57 @@ let views = { text: "This is a very long string that demonstrates the TextBox view. Use the D-Pad to scroll backwards and forwards.\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse rhoncus est malesuada quam egestas ultrices. Maecenas non eros a nulla eleifend vulputate et ut risus. Quisque in mauris mattis, venenatis risus eget, aliquam diam. Fusce pretium feugiat mauris, ut faucibus ex volutpat in. Phasellus volutpat ex sed gravida consectetur. Aliquam sed lectus feugiat, tristique lectus et, bibendum lacus. Ut sit amet augue eu sapien elementum aliquam quis vitae tortor. Vestibulum quis commodo odio. In elementum fermentum massa, eu pellentesque nibh cursus at. Integer eleifend lacus nec purus elementum sodales. Nulla elementum neque urna, non vulputate massa semper sed. Fusce ut nisi vitae dui blandit congue pretium vitae turpis.", }), stopwatchWidget: widget.makeWith({}, stopwatchWidgetElements), + buttonMenu: buttonMenuView.makeWith({ + header: "Header" + }, [ + { type: "common", label: "Test" }, + { type: "control", label: "Test2" }, + ]), + buttonPanel: buttonPanelView.makeWith({ + matrixSizeX: 2, + matrixSizeY: 2, + }, [ + { type: "button", x: 0, y: 0, matrixX: 0, matrixY: 0, icon: offIcons[0], iconSelected: offIcons[1] }, + { type: "button", x: 30, y: 30, matrixX: 1, matrixY: 1, icon: powerIcons[0], iconSelected: powerIcons[1] }, + { type: "label", x: 0, y: 50, text: "Label", font: "primary" }, + ]), + menu: menuView.makeWith({}, [ + { label: "One", icon: settingsIcon }, + { label: "Two", icon: settingsIcon }, + { label: "three", icon: settingsIcon }, + ]), + numberKbd: numberInputView.makeWith({ + header: "Number input", + defaultValue: 100, + minValue: 0, + maxValue: 200, + }), + popup: popupView.makeWith({ + header: "Hello", + text: "I'm going to be gone\nin 2 seconds", + }), + viList: viListView.makeWith({}, [ + { label: "One", variants: ["1", "1.0"] }, + { label: "Two", variants: ["2", "2.0"] }, + ]), demos: submenuView.makeWith({ header: "Choose a demo", - items: [ - "Hourglass screen", - "Empty screen", - "Text input & Dialog", - "Byte input", - "Text box", - "File picker", - "Widget", - "Exit app", - ], - }), + }, [ + "Hourglass screen", + "Empty screen", + "Text input & Dialog", + "Byte input", + "Text box", + "File picker", + "Widget", + "Button menu", + "Button panel", + "Menu", + "Number input", + "Popup", + "Var. item list", + "Exit app", + ]), }; // demo selector @@ -92,6 +141,19 @@ eventLoop.subscribe(views.demos.chosen, function (_sub, index, gui, eventLoop, v } else if (index === 6) { gui.viewDispatcher.switchTo(views.stopwatchWidget); } else if (index === 7) { + gui.viewDispatcher.switchTo(views.buttonMenu); + } else if (index === 8) { + gui.viewDispatcher.switchTo(views.buttonPanel); + } else if (index === 9) { + gui.viewDispatcher.switchTo(views.menu); + } else if (index === 10) { + gui.viewDispatcher.switchTo(views.numberKbd); + } else if (index === 11) { + views.popup.set("timeout", 2000); + gui.viewDispatcher.switchTo(views.popup); + } else if (index === 12) { + gui.viewDispatcher.switchTo(views.viList); + } else if (index === 13) { eventLoop.stop(); } }, gui, eventLoop, views); @@ -156,6 +218,42 @@ eventLoop.subscribe(eventLoop.timer("periodic", 500), function (_sub, _item, vie return [views, stopwatchWidgetElements, halfSeconds]; }, views, stopwatchWidgetElements, 0); +// go back after popup times out +eventLoop.subscribe(views.popup.timeout, function (_sub, _item, gui, views) { + gui.viewDispatcher.switchTo(views.demos); +}, gui, views); + +// button menu callback +eventLoop.subscribe(views.buttonMenu.input, function (_sub, input, gui, views) { + views.helloDialog.set("text", "You selected #" + input.index.toString()); + views.helloDialog.set("center", "Cool!"); + gui.viewDispatcher.switchTo(views.helloDialog); +}, gui, views); + +// button panel callback +eventLoop.subscribe(views.buttonPanel.input, function (_sub, input, gui, views) { + views.helloDialog.set("text", "You selected #" + input.index.toString()); + views.helloDialog.set("center", "Cool!"); + gui.viewDispatcher.switchTo(views.helloDialog); +}, gui, views); + +// menu callback +eventLoop.subscribe(views.menu.chosen, function (_sub, index, gui, views) { + views.helloDialog.set("text", "You selected #" + index.toString()); + views.helloDialog.set("center", "Cool!"); + gui.viewDispatcher.switchTo(views.helloDialog); +}, gui, views); + +// menu callback +eventLoop.subscribe(views.numberKbd.input, function (_sub, number, gui, views) { + views.helloDialog.set("text", "You typed " + number.toString()); + views.helloDialog.set("center", "Cool!"); + gui.viewDispatcher.switchTo(views.helloDialog); +}, gui, views); + +// ignore VI list +eventLoop.subscribe(views.viList.valueUpdate, function (_sub, _item) {}); + // run UI gui.viewDispatcher.switchTo(views.demos); eventLoop.run(); diff --git a/applications/system/js_app/modules/js_gui/button_menu.c b/applications/system/js_app/modules/js_gui/button_menu.c new file mode 100644 index 000000000..e247e1635 --- /dev/null +++ b/applications/system/js_app/modules/js_gui/button_menu.c @@ -0,0 +1,169 @@ +#include "../../js_modules.h" // IWYU pragma: keep +#include "js_gui.h" +#include "../js_event_loop/js_event_loop.h" +#include +#include + +typedef struct { + int32_t next_index; + StrBuffer str_buffer; + + FuriMessageQueue* input_queue; + JsEventLoopContract contract; +} JsBtnMenuContext; + +typedef struct { + int32_t index; + InputType input_type; +} JsBtnMenuEvent; + +static const char* js_input_type_to_str(InputType type) { + switch(type) { + case InputTypePress: + return "press"; + case InputTypeRelease: + return "release"; + case InputTypeShort: + return "short"; + case InputTypeLong: + return "long"; + case InputTypeRepeat: + return "repeat"; + default: + furi_crash(); + } +} + +static mjs_val_t + input_transformer(struct mjs* mjs, FuriMessageQueue* queue, JsBtnMenuContext* context) { + UNUSED(context); + JsBtnMenuEvent event; + furi_check(furi_message_queue_get(queue, &event, 0) == FuriStatusOk); + + mjs_val_t event_obj = mjs_mk_object(mjs); + JS_ASSIGN_MULTI(mjs, event_obj) { + JS_FIELD("index", mjs_mk_number(mjs, event.index)); + JS_FIELD("type", mjs_mk_string(mjs, js_input_type_to_str(event.input_type), ~0, false)); + } + + return event_obj; +} + +static void input_callback(void* ctx, int32_t index, InputType type) { + JsBtnMenuContext* context = ctx; + JsBtnMenuEvent event = { + .index = index, + .input_type = type, + }; + furi_check(furi_message_queue_put(context->input_queue, &event, 0) == FuriStatusOk); +} + +static bool matrix_header_assign( + struct mjs* mjs, + ButtonMenu* menu, + JsViewPropValue value, + JsBtnMenuContext* context) { + UNUSED(mjs); + button_menu_set_header(menu, str_buffer_make_owned_clone(&context->str_buffer, value.string)); + return true; +} + +static bool js_button_menu_add_child( + struct mjs* mjs, + ButtonMenu* menu, + JsBtnMenuContext* context, + mjs_val_t child_obj) { + static const JsValueEnumVariant js_button_menu_item_type_variants[] = { + {"common", ButtonMenuItemTypeCommon}, + {"control", ButtonMenuItemTypeControl}, + }; + static const JsValueDeclaration js_button_menu_item_type = + JS_VALUE_ENUM(ButtonMenuItemType, js_button_menu_item_type_variants); + + static const JsValueDeclaration js_button_menu_string = JS_VALUE_SIMPLE(JsValueTypeString); + + static const JsValueObjectField js_button_menu_child_fields[] = { + {"type", &js_button_menu_item_type}, + {"label", &js_button_menu_string}, + }; + static const JsValueDeclaration js_button_menu_child = + JS_VALUE_OBJECT(js_button_menu_child_fields); + + ButtonMenuItemType item_type; + const char* label; + JsValueParseStatus status; + JS_VALUE_PARSE( + mjs, + JS_VALUE_PARSE_SOURCE_VALUE(&js_button_menu_child), + JsValueParseFlagReturnOnError, + &status, + &child_obj, + &item_type, + &label); + if(status != JsValueParseStatusOk) return false; + + button_menu_add_item( + menu, + str_buffer_make_owned_clone(&context->str_buffer, label), + context->next_index++, + input_callback, + item_type, + context); + + return true; +} + +static void js_button_menu_reset_children(ButtonMenu* menu, JsBtnMenuContext* context) { + context->next_index = 0; + button_menu_reset(menu); + str_buffer_clear_all_clones(&context->str_buffer); +} + +static JsBtnMenuContext* ctx_make(struct mjs* mjs, ButtonMenu* menu, mjs_val_t view_obj) { + UNUSED(menu); + JsBtnMenuContext* context = malloc(sizeof(JsBtnMenuContext)); + *context = (JsBtnMenuContext){ + .next_index = 0, + .str_buffer = {0}, + .input_queue = furi_message_queue_alloc(1, sizeof(JsBtnMenuEvent)), + }; + context->contract = (JsEventLoopContract){ + .magic = JsForeignMagic_JsEventLoopContract, + .object_type = JsEventLoopObjectTypeQueue, + .object = context->input_queue, + .non_timer = + { + .event = FuriEventLoopEventIn, + .transformer = (JsEventLoopTransformer)input_transformer, + .transformer_context = context, + }, + }; + mjs_set(mjs, view_obj, "input", ~0, mjs_mk_foreign(mjs, &context->contract)); + return context; +} + +static void ctx_destroy(ButtonMenu* input, JsBtnMenuContext* context, FuriEventLoop* loop) { + UNUSED(input); + furi_event_loop_maybe_unsubscribe(loop, context->input_queue); + furi_message_queue_free(context->input_queue); + str_buffer_clear_all_clones(&context->str_buffer); + free(context); +} + +static const JsViewDescriptor view_descriptor = { + .alloc = (JsViewAlloc)button_menu_alloc, + .free = (JsViewFree)button_menu_free, + .get_view = (JsViewGetView)button_menu_get_view, + .custom_make = (JsViewCustomMake)ctx_make, + .custom_destroy = (JsViewCustomDestroy)ctx_destroy, + .add_child = (JsViewAddChild)js_button_menu_add_child, + .reset_children = (JsViewResetChildren)js_button_menu_reset_children, + .prop_cnt = 1, + .props = { + (JsViewPropDescriptor){ + .name = "header", + .type = JsViewPropTypeString, + .assign = (JsViewPropAssign)matrix_header_assign}, + }}; + +JS_GUI_VIEW_DEF(button_menu, &view_descriptor); diff --git a/applications/system/js_app/modules/js_gui/button_panel.c b/applications/system/js_app/modules/js_gui/button_panel.c new file mode 100644 index 000000000..ebd4edb57 --- /dev/null +++ b/applications/system/js_app/modules/js_gui/button_panel.c @@ -0,0 +1,274 @@ +#include "../../js_modules.h" // IWYU pragma: keep +#include "js_gui.h" +#include "../js_event_loop/js_event_loop.h" +#include +#include + +typedef struct { + size_t matrix_x, matrix_y; + int32_t next_index; + StrBuffer str_buffer; + + FuriMessageQueue* input_queue; + JsEventLoopContract contract; +} JsBtnPanelContext; + +typedef struct { + int32_t index; + InputType input_type; +} JsBtnPanelEvent; + +static const char* js_input_type_to_str(InputType type) { + switch(type) { + case InputTypePress: + return "press"; + case InputTypeRelease: + return "release"; + case InputTypeShort: + return "short"; + case InputTypeLong: + return "long"; + case InputTypeRepeat: + return "repeat"; + default: + furi_crash(); + } +} + +static mjs_val_t + input_transformer(struct mjs* mjs, FuriMessageQueue* queue, JsBtnPanelContext* context) { + UNUSED(context); + JsBtnPanelEvent event; + furi_check(furi_message_queue_get(queue, &event, 0) == FuriStatusOk); + + mjs_val_t event_obj = mjs_mk_object(mjs); + JS_ASSIGN_MULTI(mjs, event_obj) { + JS_FIELD("index", mjs_mk_number(mjs, event.index)); + JS_FIELD("type", mjs_mk_string(mjs, js_input_type_to_str(event.input_type), ~0, false)); + } + + return event_obj; +} + +static void input_callback(void* ctx, int32_t index, InputType type) { + JsBtnPanelContext* context = ctx; + JsBtnPanelEvent event = { + .index = index, + .input_type = type, + }; + furi_check(furi_message_queue_put(context->input_queue, &event, 0) == FuriStatusOk); +} + +static bool matrix_size_x_assign( + struct mjs* mjs, + ButtonPanel* panel, + JsViewPropValue value, + JsBtnPanelContext* context) { + UNUSED(mjs); + context->matrix_x = value.number; + button_panel_reserve(panel, context->matrix_x, context->matrix_y); + return true; +} + +static bool matrix_size_y_assign( + struct mjs* mjs, + ButtonPanel* panel, + JsViewPropValue value, + JsBtnPanelContext* context) { + UNUSED(mjs); + context->matrix_y = value.number; + button_panel_reserve(panel, context->matrix_x, context->matrix_y); + return true; +} + +static bool js_button_panel_add_child( + struct mjs* mjs, + ButtonPanel* panel, + JsBtnPanelContext* context, + mjs_val_t child_obj) { + typedef enum { + JsButtonPanelChildTypeButton, + JsButtonPanelChildTypeLabel, + JsButtonPanelChildTypeIcon, + } JsButtonPanelChildType; + static const JsValueEnumVariant js_button_panel_child_type_variants[] = { + {"button", JsButtonPanelChildTypeButton}, + {"label", JsButtonPanelChildTypeLabel}, + {"icon", JsButtonPanelChildTypeIcon}, + }; + static const JsValueDeclaration js_button_panel_child_type = + JS_VALUE_ENUM(JsButtonPanelChildType, js_button_panel_child_type_variants); + + static const JsValueDeclaration js_button_panel_number = JS_VALUE_SIMPLE(JsValueTypeInt32); + static const JsValueObjectField js_button_panel_common_fields[] = { + {"type", &js_button_panel_child_type}, + {"x", &js_button_panel_number}, + {"y", &js_button_panel_number}, + }; + static const JsValueDeclaration js_button_panel_common = + JS_VALUE_OBJECT(js_button_panel_common_fields); + + static const JsValueDeclaration js_button_panel_pointer = + JS_VALUE_SIMPLE(JsValueTypeRawPointer); + static const JsValueObjectField js_button_panel_button_fields[] = { + {"matrixX", &js_button_panel_number}, + {"matrixY", &js_button_panel_number}, + {"icon", &js_button_panel_pointer}, + {"iconSelected", &js_button_panel_pointer}, + }; + static const JsValueDeclaration js_button_panel_button = + JS_VALUE_OBJECT(js_button_panel_button_fields); + + static const JsValueDeclaration js_button_panel_string = JS_VALUE_SIMPLE(JsValueTypeString); + static const JsValueObjectField js_button_panel_label_fields[] = { + {"text", &js_button_panel_string}, + {"font", &js_gui_font_declaration}, + }; + static const JsValueDeclaration js_button_panel_label = + JS_VALUE_OBJECT(js_button_panel_label_fields); + + static const JsValueObjectField js_button_panel_icon_fields[] = { + {"icon", &js_button_panel_pointer}, + }; + static const JsValueDeclaration js_button_panel_icon = + JS_VALUE_OBJECT(js_button_panel_icon_fields); + + JsButtonPanelChildType child_type; + int32_t x, y; + JsValueParseStatus status; + JS_VALUE_PARSE( + mjs, + JS_VALUE_PARSE_SOURCE_VALUE(&js_button_panel_common), + JsValueParseFlagReturnOnError, + &status, + &child_obj, + &child_type, + &x, + &y); + if(status != JsValueParseStatusOk) return false; + + switch(child_type) { + case JsButtonPanelChildTypeButton: { + int32_t matrix_x, matrix_y; + const Icon *icon, *icon_selected; + JS_VALUE_PARSE( + mjs, + JS_VALUE_PARSE_SOURCE_VALUE(&js_button_panel_button), + JsValueParseFlagReturnOnError, + &status, + &child_obj, + &matrix_x, + &matrix_y, + &icon, + &icon_selected); + if(status != JsValueParseStatusOk) return false; + button_panel_add_item( + panel, + context->next_index++, + matrix_x, + matrix_y, + x, + y, + icon, + icon_selected, + (ButtonItemCallback)input_callback, + context); + break; + } + + case JsButtonPanelChildTypeLabel: { + const char* text; + Font font; + JS_VALUE_PARSE( + mjs, + JS_VALUE_PARSE_SOURCE_VALUE(&js_button_panel_label), + JsValueParseFlagReturnOnError, + &status, + &child_obj, + &text, + &font); + if(status != JsValueParseStatusOk) return false; + button_panel_add_label( + panel, x, y, font, str_buffer_make_owned_clone(&context->str_buffer, text)); + break; + } + + case JsButtonPanelChildTypeIcon: { + const Icon* icon; + JS_VALUE_PARSE( + mjs, + JS_VALUE_PARSE_SOURCE_VALUE(&js_button_panel_icon), + JsValueParseFlagReturnOnError, + &status, + &child_obj, + &icon); + if(status != JsValueParseStatusOk) return false; + button_panel_add_icon(panel, x, y, icon); + break; + } + } + + return true; +} + +static void js_button_panel_reset_children(ButtonPanel* panel, JsBtnPanelContext* context) { + context->next_index = 0; + button_panel_reset(panel); + button_panel_reserve(panel, context->matrix_x, context->matrix_y); + str_buffer_clear_all_clones(&context->str_buffer); +} + +static JsBtnPanelContext* ctx_make(struct mjs* mjs, ButtonPanel* panel, mjs_val_t view_obj) { + UNUSED(panel); + JsBtnPanelContext* context = malloc(sizeof(JsBtnPanelContext)); + *context = (JsBtnPanelContext){ + .matrix_x = 1, + .matrix_y = 1, + .next_index = 0, + .str_buffer = {0}, + .input_queue = furi_message_queue_alloc(1, sizeof(JsBtnPanelEvent)), + }; + context->contract = (JsEventLoopContract){ + .magic = JsForeignMagic_JsEventLoopContract, + .object_type = JsEventLoopObjectTypeQueue, + .object = context->input_queue, + .non_timer = + { + .event = FuriEventLoopEventIn, + .transformer = (JsEventLoopTransformer)input_transformer, + .transformer_context = context, + }, + }; + mjs_set(mjs, view_obj, "input", ~0, mjs_mk_foreign(mjs, &context->contract)); + return context; +} + +static void ctx_destroy(ButtonPanel* input, JsBtnPanelContext* context, FuriEventLoop* loop) { + UNUSED(input); + furi_event_loop_maybe_unsubscribe(loop, context->input_queue); + furi_message_queue_free(context->input_queue); + str_buffer_clear_all_clones(&context->str_buffer); + free(context); +} + +static const JsViewDescriptor view_descriptor = { + .alloc = (JsViewAlloc)button_panel_alloc, + .free = (JsViewFree)button_panel_free, + .get_view = (JsViewGetView)button_panel_get_view, + .custom_make = (JsViewCustomMake)ctx_make, + .custom_destroy = (JsViewCustomDestroy)ctx_destroy, + .add_child = (JsViewAddChild)js_button_panel_add_child, + .reset_children = (JsViewResetChildren)js_button_panel_reset_children, + .prop_cnt = 2, + .props = { + (JsViewPropDescriptor){ + .name = "matrixSizeX", + .type = JsViewPropTypeNumber, + .assign = (JsViewPropAssign)matrix_size_x_assign}, + (JsViewPropDescriptor){ + .name = "matrixSizeY", + .type = JsViewPropTypeNumber, + .assign = (JsViewPropAssign)matrix_size_y_assign}, + }}; + +JS_GUI_VIEW_DEF(button_panel, &view_descriptor); diff --git a/applications/system/js_app/modules/js_gui/icon.c b/applications/system/js_app/modules/js_gui/icon.c index 4fc6da2e0..11e26d02f 100644 --- a/applications/system/js_app/modules/js_gui/icon.c +++ b/applications/system/js_app/modules/js_gui/icon.c @@ -14,9 +14,19 @@ typedef struct { .name = #icon, .data = &I_##icon \ } +#define ANIM_ICON_DEF(icon) \ + (IconDefinition) { \ + .name = #icon, .data = &A_##icon \ + } + static const IconDefinition builtin_icons[] = { ICON_DEF(DolphinWait_59x54), ICON_DEF(js_script_10px), + ICON_DEF(off_19x20), + ICON_DEF(off_hover_19x20), + ICON_DEF(power_19x20), + ICON_DEF(power_hover_19x20), + ANIM_ICON_DEF(Settings_14), }; // Firmware's Icon struct needs a frames array, and uses a small CompressHeader diff --git a/applications/system/js_app/modules/js_gui/js_gui.c b/applications/system/js_app/modules/js_gui/js_gui.c index c20d980aa..2f7825717 100644 --- a/applications/system/js_app/modules/js_gui/js_gui.c +++ b/applications/system/js_app/modules/js_gui/js_gui.c @@ -31,6 +31,14 @@ typedef struct { void* custom_data; } JsGuiViewData; +static const JsValueEnumVariant js_gui_font_variants[] = { + {"primary", FontPrimary}, + {"secondary", FontSecondary}, + {"keyboard", FontKeyboard}, + {"bit_numbers", FontBigNumbers}, +}; +const JsValueDeclaration js_gui_font_declaration = JS_VALUE_ENUM(Font, js_gui_font_variants); + /** * @brief Transformer for custom events */ @@ -273,9 +281,12 @@ static bool /** * @brief Sets the list of children. Not available from JS. */ -static bool - js_gui_view_internal_set_children(struct mjs* mjs, mjs_val_t children, JsGuiViewData* data) { - data->descriptor->reset_children(data->specific_view, data->custom_data); +static bool js_gui_view_internal_set_children( + struct mjs* mjs, + mjs_val_t children, + JsGuiViewData* data, + bool do_reset) { + if(do_reset) data->descriptor->reset_children(data->specific_view, data->custom_data); for(size_t i = 0; i < mjs_array_length(mjs, children); i++) { mjs_val_t child = mjs_array_get(mjs, children, i); @@ -357,7 +368,7 @@ static void js_gui_view_set_children(struct mjs* mjs) { if(!data->descriptor->add_child || !data->descriptor->reset_children) JS_ERROR_AND_RETURN(mjs, MJS_BAD_ARGS_ERROR, "this View can't have children"); - js_gui_view_internal_set_children(mjs, children, data); + js_gui_view_internal_set_children(mjs, children, data, true); } /** @@ -450,7 +461,7 @@ static void js_gui_vf_make_with(struct mjs* mjs) { if(!data->descriptor->add_child || !data->descriptor->reset_children) JS_ERROR_AND_RETURN(mjs, MJS_BAD_ARGS_ERROR, "this View can't have children"); - if(!js_gui_view_internal_set_children(mjs, children, data)) return; + if(!js_gui_view_internal_set_children(mjs, children, data, false)) return; } mjs_return(mjs, view_obj); diff --git a/applications/system/js_app/modules/js_gui/js_gui.h b/applications/system/js_app/modules/js_gui/js_gui.h index d9d98df39..552eaee7c 100644 --- a/applications/system/js_app/modules/js_gui/js_gui.h +++ b/applications/system/js_app/modules/js_gui/js_gui.h @@ -20,6 +20,11 @@ typedef union { mjs_val_t term; } JsViewPropValue; +/** + * JS-to-C font enum mapping + */ +extern const JsValueDeclaration js_gui_font_declaration; + /** * @brief Assigns a value to a view property * diff --git a/applications/system/js_app/modules/js_gui/js_gui_api_table_i.h b/applications/system/js_app/modules/js_gui/js_gui_api_table_i.h index 852b3d107..c83a38e30 100644 --- a/applications/system/js_app/modules/js_gui/js_gui_api_table_i.h +++ b/applications/system/js_app/modules/js_gui/js_gui_api_table_i.h @@ -1,4 +1,5 @@ #include "js_gui.h" static constexpr auto js_gui_api_table = sort(create_array_t( - API_METHOD(js_gui_make_view_factory, mjs_val_t, (struct mjs*, const JsViewDescriptor*)))); + API_METHOD(js_gui_make_view_factory, mjs_val_t, (struct mjs*, const JsViewDescriptor*)), + API_VARIABLE(js_gui_font_declaration, const JsValueDeclaration))); diff --git a/applications/system/js_app/modules/js_gui/menu.c b/applications/system/js_app/modules/js_gui/menu.c new file mode 100644 index 000000000..c8e23470f --- /dev/null +++ b/applications/system/js_app/modules/js_gui/menu.c @@ -0,0 +1,105 @@ +#include "../../js_modules.h" // IWYU pragma: keep +#include "js_gui.h" +#include "../js_event_loop/js_event_loop.h" +#include +#include + +typedef struct { + int32_t next_index; + StrBuffer str_buffer; + + FuriMessageQueue* queue; + JsEventLoopContract contract; +} JsMenuCtx; + +static mjs_val_t choose_transformer(struct mjs* mjs, FuriMessageQueue* queue, void* context) { + UNUSED(context); + uint32_t index; + furi_check(furi_message_queue_get(queue, &index, 0) == FuriStatusOk); + return mjs_mk_number(mjs, (double)index); +} + +static void choose_callback(void* context, uint32_t index) { + JsMenuCtx* ctx = context; + furi_check(furi_message_queue_put(ctx->queue, &index, 0) == FuriStatusOk); +} + +static bool + js_menu_add_child(struct mjs* mjs, Menu* menu, JsMenuCtx* context, mjs_val_t child_obj) { + static const JsValueDeclaration js_menu_string = JS_VALUE_SIMPLE(JsValueTypeString); + static const JsValueDeclaration js_menu_pointer = JS_VALUE_SIMPLE(JsValueTypeRawPointer); + + static const JsValueObjectField js_menu_child_fields[] = { + {"icon", &js_menu_pointer}, + {"label", &js_menu_string}, + }; + static const JsValueDeclaration js_menu_child = JS_VALUE_OBJECT(js_menu_child_fields); + + const Icon* icon; + const char* label; + JsValueParseStatus status; + JS_VALUE_PARSE( + mjs, + JS_VALUE_PARSE_SOURCE_VALUE(&js_menu_child), + JsValueParseFlagReturnOnError, + &status, + &child_obj, + &icon, + &label); + if(status != JsValueParseStatusOk) return false; + + menu_add_item( + menu, + str_buffer_make_owned_clone(&context->str_buffer, label), + icon, + context->next_index++, + choose_callback, + context); + + return true; +} + +static void js_menu_reset_children(Menu* menu, JsMenuCtx* context) { + context->next_index = 0; + menu_reset(menu); + str_buffer_clear_all_clones(&context->str_buffer); +} + +static JsMenuCtx* ctx_make(struct mjs* mjs, Menu* input, mjs_val_t view_obj) { + UNUSED(input); + JsMenuCtx* context = malloc(sizeof(JsMenuCtx)); + context->queue = furi_message_queue_alloc(1, sizeof(uint32_t)); + context->contract = (JsEventLoopContract){ + .magic = JsForeignMagic_JsEventLoopContract, + .object_type = JsEventLoopObjectTypeQueue, + .object = context->queue, + .non_timer = + { + .event = FuriEventLoopEventIn, + .transformer = (JsEventLoopTransformer)choose_transformer, + }, + }; + mjs_set(mjs, view_obj, "chosen", ~0, mjs_mk_foreign(mjs, &context->contract)); + return context; +} + +static void ctx_destroy(Menu* input, JsMenuCtx* context, FuriEventLoop* loop) { + UNUSED(input); + furi_event_loop_maybe_unsubscribe(loop, context->queue); + furi_message_queue_free(context->queue); + str_buffer_clear_all_clones(&context->str_buffer); + free(context); +} + +static const JsViewDescriptor view_descriptor = { + .alloc = (JsViewAlloc)menu_alloc, + .free = (JsViewFree)menu_free, + .get_view = (JsViewGetView)menu_get_view, + .custom_make = (JsViewCustomMake)ctx_make, + .custom_destroy = (JsViewCustomDestroy)ctx_destroy, + .add_child = (JsViewAddChild)js_menu_add_child, + .reset_children = (JsViewResetChildren)js_menu_reset_children, + .prop_cnt = 0, + .props = {}, +}; +JS_GUI_VIEW_DEF(menu, &view_descriptor); diff --git a/applications/system/js_app/modules/js_gui/number_input.c b/applications/system/js_app/modules/js_gui/number_input.c new file mode 100644 index 000000000..2a1aa6ee6 --- /dev/null +++ b/applications/system/js_app/modules/js_gui/number_input.c @@ -0,0 +1,130 @@ +#include "../../js_modules.h" // IWYU pragma: keep +#include "js_gui.h" +#include "../js_event_loop/js_event_loop.h" +#include + +typedef struct { + int32_t default_val, min_val, max_val; + FuriMessageQueue* input_queue; + JsEventLoopContract contract; +} JsNumKbdContext; + +static mjs_val_t + input_transformer(struct mjs* mjs, FuriMessageQueue* queue, JsNumKbdContext* context) { + UNUSED(context); + int32_t number; + furi_check(furi_message_queue_get(queue, &number, 0) == FuriStatusOk); + return mjs_mk_number(mjs, number); +} + +static void input_callback(void* ctx, int32_t value) { + JsNumKbdContext* context = ctx; + furi_check(furi_message_queue_put(context->input_queue, &value, 0) == FuriStatusOk); +} + +static bool header_assign( + struct mjs* mjs, + NumberInput* input, + JsViewPropValue value, + JsNumKbdContext* context) { + UNUSED(mjs); + UNUSED(context); + number_input_set_header_text(input, value.string); + return true; +} + +static bool min_val_assign( + struct mjs* mjs, + NumberInput* input, + JsViewPropValue value, + JsNumKbdContext* context) { + UNUSED(mjs); + context->min_val = value.number; + number_input_set_result_callback( + input, input_callback, context, context->default_val, context->min_val, context->max_val); + return true; +} + +static bool max_val_assign( + struct mjs* mjs, + NumberInput* input, + JsViewPropValue value, + JsNumKbdContext* context) { + UNUSED(mjs); + context->max_val = value.number; + number_input_set_result_callback( + input, input_callback, context, context->default_val, context->min_val, context->max_val); + return true; +} + +static bool default_val_assign( + struct mjs* mjs, + NumberInput* input, + JsViewPropValue value, + JsNumKbdContext* context) { + UNUSED(mjs); + context->default_val = value.number; + number_input_set_result_callback( + input, input_callback, context, context->default_val, context->min_val, context->max_val); + return true; +} + +static JsNumKbdContext* ctx_make(struct mjs* mjs, NumberInput* input, mjs_val_t view_obj) { + JsNumKbdContext* context = malloc(sizeof(JsNumKbdContext)); + *context = (JsNumKbdContext){ + .default_val = 0, + .max_val = 100, + .min_val = 0, + .input_queue = furi_message_queue_alloc(1, sizeof(int32_t)), + }; + context->contract = (JsEventLoopContract){ + .magic = JsForeignMagic_JsEventLoopContract, + .object_type = JsEventLoopObjectTypeQueue, + .object = context->input_queue, + .non_timer = + { + .event = FuriEventLoopEventIn, + .transformer = (JsEventLoopTransformer)input_transformer, + .transformer_context = context, + }, + }; + number_input_set_result_callback( + input, input_callback, context, context->default_val, context->min_val, context->max_val); + mjs_set(mjs, view_obj, "input", ~0, mjs_mk_foreign(mjs, &context->contract)); + return context; +} + +static void ctx_destroy(NumberInput* input, JsNumKbdContext* context, FuriEventLoop* loop) { + UNUSED(input); + furi_event_loop_maybe_unsubscribe(loop, context->input_queue); + furi_message_queue_free(context->input_queue); + free(context); +} + +static const JsViewDescriptor view_descriptor = { + .alloc = (JsViewAlloc)number_input_alloc, + .free = (JsViewFree)number_input_free, + .get_view = (JsViewGetView)number_input_get_view, + .custom_make = (JsViewCustomMake)ctx_make, + .custom_destroy = (JsViewCustomDestroy)ctx_destroy, + .prop_cnt = 4, + .props = { + (JsViewPropDescriptor){ + .name = "header", + .type = JsViewPropTypeString, + .assign = (JsViewPropAssign)header_assign}, + (JsViewPropDescriptor){ + .name = "minValue", + .type = JsViewPropTypeNumber, + .assign = (JsViewPropAssign)min_val_assign}, + (JsViewPropDescriptor){ + .name = "maxValue", + .type = JsViewPropTypeNumber, + .assign = (JsViewPropAssign)max_val_assign}, + (JsViewPropDescriptor){ + .name = "defaultValue", + .type = JsViewPropTypeNumber, + .assign = (JsViewPropAssign)default_val_assign}, + }}; + +JS_GUI_VIEW_DEF(number_input, &view_descriptor); diff --git a/applications/system/js_app/modules/js_gui/popup.c b/applications/system/js_app/modules/js_gui/popup.c new file mode 100644 index 000000000..65f827ab1 --- /dev/null +++ b/applications/system/js_app/modules/js_gui/popup.c @@ -0,0 +1,102 @@ +#include "../../js_modules.h" // IWYU pragma: keep +#include "js_gui.h" +#include "../js_event_loop/js_event_loop.h" +#include +#include + +typedef struct { + StrBuffer str_buffer; + FuriSemaphore* semaphore; + JsEventLoopContract contract; +} JsPopupCtx; + +static void timeout_callback(JsPopupCtx* context) { + furi_check(furi_semaphore_release(context->semaphore) == FuriStatusOk); +} + +static bool + header_assign(struct mjs* mjs, Popup* popup, JsViewPropValue value, JsPopupCtx* context) { + UNUSED(mjs); + UNUSED(context); + popup_set_header( + popup, + str_buffer_make_owned_clone(&context->str_buffer, value.string), + 64, + 0, + AlignCenter, + AlignTop); + return true; +} + +static bool + text_assign(struct mjs* mjs, Popup* popup, JsViewPropValue value, JsPopupCtx* context) { + UNUSED(mjs); + UNUSED(context); + popup_set_text( + popup, + str_buffer_make_owned_clone(&context->str_buffer, value.string), + 64, + 32, + AlignCenter, + AlignCenter); + return true; +} + +static bool + timeout_assign(struct mjs* mjs, Popup* popup, JsViewPropValue value, JsPopupCtx* context) { + UNUSED(mjs); + UNUSED(context); + popup_set_timeout(popup, value.number); + popup_enable_timeout(popup); + return true; +} + +static JsPopupCtx* ctx_make(struct mjs* mjs, Popup* popup, mjs_val_t view_obj) { + JsPopupCtx* context = malloc(sizeof(JsPopupCtx)); + context->semaphore = furi_semaphore_alloc(1, 0); + context->contract = (JsEventLoopContract){ + .magic = JsForeignMagic_JsEventLoopContract, + .object_type = JsEventLoopObjectTypeSemaphore, + .object = context->semaphore, + .non_timer = + { + .event = FuriEventLoopEventIn, + }, + }; + mjs_set(mjs, view_obj, "timeout", ~0, mjs_mk_foreign(mjs, &context->contract)); + popup_set_callback(popup, (PopupCallback)timeout_callback); + popup_set_context(popup, context); + return context; +} + +static void ctx_destroy(Popup* popup, JsPopupCtx* context, FuriEventLoop* loop) { + UNUSED(popup); + furi_event_loop_maybe_unsubscribe(loop, context->semaphore); + furi_semaphore_free(context->semaphore); + str_buffer_clear_all_clones(&context->str_buffer); + free(context); +} + +static const JsViewDescriptor view_descriptor = { + .alloc = (JsViewAlloc)popup_alloc, + .free = (JsViewFree)popup_free, + .get_view = (JsViewGetView)popup_get_view, + .custom_make = (JsViewCustomMake)ctx_make, + .custom_destroy = (JsViewCustomDestroy)ctx_destroy, + .prop_cnt = 3, + .props = { + (JsViewPropDescriptor){ + .name = "header", + .type = JsViewPropTypeString, + .assign = (JsViewPropAssign)header_assign}, + (JsViewPropDescriptor){ + .name = "text", + .type = JsViewPropTypeString, + .assign = (JsViewPropAssign)text_assign}, + (JsViewPropDescriptor){ + .name = "timeout", + .type = JsViewPropTypeNumber, + .assign = (JsViewPropAssign)timeout_assign}, + }}; + +JS_GUI_VIEW_DEF(popup, &view_descriptor); diff --git a/applications/system/js_app/modules/js_gui/submenu.c b/applications/system/js_app/modules/js_gui/submenu.c index c142bcddb..64ab1b906 100644 --- a/applications/system/js_app/modules/js_gui/submenu.c +++ b/applications/system/js_app/modules/js_gui/submenu.c @@ -6,6 +6,7 @@ #define QUEUE_LEN 2 typedef struct { + int32_t next_index; FuriMessageQueue* queue; JsEventLoopContract contract; } JsSubmenuCtx; @@ -30,18 +31,24 @@ static bool return true; } -static bool items_assign(struct mjs* mjs, Submenu* submenu, JsViewPropValue value, void* context) { - UNUSED(mjs); - submenu_reset(submenu); - size_t len = mjs_array_length(mjs, value.term); - for(size_t i = 0; i < len; i++) { - mjs_val_t item = mjs_array_get(mjs, value.term, i); - if(!mjs_is_string(item)) return false; - submenu_add_item(submenu, mjs_get_string(mjs, &item, NULL), i, choose_callback, context); - } +static bool js_submenu_add_child( + struct mjs* mjs, + Submenu* submenu, + JsSubmenuCtx* context, + mjs_val_t child_obj) { + const char* str = mjs_get_string(mjs, &child_obj, NULL); + if(!str) return false; + + submenu_add_item(submenu, str, context->next_index++, choose_callback, context); + return true; } +static void js_submenu_reset_children(Submenu* submenu, JsSubmenuCtx* context) { + context->next_index = 0; + submenu_reset(submenu); +} + static JsSubmenuCtx* ctx_make(struct mjs* mjs, Submenu* input, mjs_val_t view_obj) { UNUSED(input); JsSubmenuCtx* context = malloc(sizeof(JsSubmenuCtx)); @@ -73,15 +80,13 @@ static const JsViewDescriptor view_descriptor = { .get_view = (JsViewGetView)submenu_get_view, .custom_make = (JsViewCustomMake)ctx_make, .custom_destroy = (JsViewCustomDestroy)ctx_destroy, - .prop_cnt = 2, + .add_child = (JsViewAddChild)js_submenu_add_child, + .reset_children = (JsViewResetChildren)js_submenu_reset_children, + .prop_cnt = 1, .props = { (JsViewPropDescriptor){ .name = "header", .type = JsViewPropTypeString, .assign = (JsViewPropAssign)header_assign}, - (JsViewPropDescriptor){ - .name = "items", - .type = JsViewPropTypeArr, - .assign = (JsViewPropAssign)items_assign}, }}; JS_GUI_VIEW_DEF(submenu, &view_descriptor); diff --git a/applications/system/js_app/modules/js_gui/vi_list.c b/applications/system/js_app/modules/js_gui/vi_list.c new file mode 100644 index 000000000..bdea2fba1 --- /dev/null +++ b/applications/system/js_app/modules/js_gui/vi_list.c @@ -0,0 +1,163 @@ +#include "../../js_modules.h" // IWYU pragma: keep +#include "js_gui.h" +#include "../js_event_loop/js_event_loop.h" +#include +#include + +typedef struct { + StrBuffer str_buffer; + + // let mjs do the memory management heavy lifting, store children in a js array + struct mjs* mjs; + mjs_val_t children; + VariableItemList* list; + + FuriMessageQueue* input_queue; + JsEventLoopContract contract; +} JsViListContext; + +typedef struct { + int32_t item_index; + int32_t value_index; +} JsViListEvent; + +static mjs_val_t + input_transformer(struct mjs* mjs, FuriMessageQueue* queue, JsViListContext* context) { + UNUSED(context); + JsViListEvent event; + furi_check(furi_message_queue_get(queue, &event, 0) == FuriStatusOk); + + mjs_val_t event_obj = mjs_mk_object(mjs); + JS_ASSIGN_MULTI(mjs, event_obj) { + JS_FIELD("itemIndex", mjs_mk_number(mjs, event.item_index)); + JS_FIELD("valueIndex", mjs_mk_number(mjs, event.value_index)); + } + + return event_obj; +} + +static void js_vi_list_change_callback(VariableItem* item) { + JsViListContext* context = variable_item_get_context(item); + struct mjs* mjs = context->mjs; + uint8_t item_index = variable_item_list_get_selected_item_index(context->list); + uint8_t value_index = variable_item_get_current_value_index(item); + + // type safety ensured in add_child + mjs_val_t variants = mjs_array_get(mjs, context->children, item_index); + mjs_val_t variant = mjs_array_get(mjs, variants, value_index); + variable_item_set_current_value_text(item, mjs_get_string(mjs, &variant, NULL)); + + JsViListEvent event = { + .item_index = item_index, + .value_index = value_index, + }; + furi_check(furi_message_queue_put(context->input_queue, &event, 0) == FuriStatusOk); +} + +static bool js_vi_list_add_child( + struct mjs* mjs, + VariableItemList* list, + JsViListContext* context, + mjs_val_t child_obj) { + static const JsValueDeclaration js_vi_list_string = JS_VALUE_SIMPLE(JsValueTypeString); + static const JsValueDeclaration js_vi_list_arr = JS_VALUE_SIMPLE(JsValueTypeAnyArray); + static const JsValueDeclaration js_vi_list_int_default_0 = + JS_VALUE_SIMPLE_W_DEFAULT(JsValueTypeInt32, int32_val, 0); + + static const JsValueObjectField js_vi_list_child_fields[] = { + {"label", &js_vi_list_string}, + {"variants", &js_vi_list_arr}, + {"defaultSelected", &js_vi_list_int_default_0}, + }; + static const JsValueDeclaration js_vi_list_child = + JS_VALUE_OBJECT_W_DEFAULTS(js_vi_list_child_fields); + + JsValueParseStatus status; + const char* label; + mjs_val_t variants; + int32_t default_selected; + JS_VALUE_PARSE( + mjs, + JS_VALUE_PARSE_SOURCE_VALUE(&js_vi_list_child), + JsValueParseFlagReturnOnError, + &status, + &child_obj, + &label, + &variants, + &default_selected); + if(status != JsValueParseStatusOk) return false; + + size_t variants_cnt = mjs_array_length(mjs, variants); + for(size_t i = 0; i < variants_cnt; i++) + if(!mjs_is_string(mjs_array_get(mjs, variants, i))) return false; + + VariableItem* item = variable_item_list_add( + list, + str_buffer_make_owned_clone(&context->str_buffer, label), + variants_cnt, + js_vi_list_change_callback, + context); + variable_item_set_current_value_index(item, default_selected); + mjs_val_t default_variant = mjs_array_get(mjs, variants, default_selected); + variable_item_set_current_value_text(item, mjs_get_string(mjs, &default_variant, NULL)); + + mjs_array_push(context->mjs, context->children, variants); + + return true; +} + +static void js_vi_list_reset_children(VariableItemList* list, JsViListContext* context) { + mjs_disown(context->mjs, &context->children); + context->children = mjs_mk_array(context->mjs); + mjs_own(context->mjs, &context->children); + + variable_item_list_reset(list); + str_buffer_clear_all_clones(&context->str_buffer); +} + +static JsViListContext* ctx_make(struct mjs* mjs, VariableItemList* list, mjs_val_t view_obj) { + JsViListContext* context = malloc(sizeof(JsViListContext)); + *context = (JsViListContext){ + .str_buffer = {0}, + .mjs = mjs, + .children = mjs_mk_array(mjs), + .list = list, + .input_queue = furi_message_queue_alloc(1, sizeof(JsViListEvent)), + }; + mjs_own(context->mjs, &context->children); + context->contract = (JsEventLoopContract){ + .magic = JsForeignMagic_JsEventLoopContract, + .object_type = JsEventLoopObjectTypeQueue, + .object = context->input_queue, + .non_timer = + { + .event = FuriEventLoopEventIn, + .transformer = (JsEventLoopTransformer)input_transformer, + .transformer_context = context, + }, + }; + mjs_set(mjs, view_obj, "valueUpdate", ~0, mjs_mk_foreign(mjs, &context->contract)); + return context; +} + +static void ctx_destroy(VariableItemList* input, JsViListContext* context, FuriEventLoop* loop) { + UNUSED(input); + furi_event_loop_maybe_unsubscribe(loop, context->input_queue); + furi_message_queue_free(context->input_queue); + str_buffer_clear_all_clones(&context->str_buffer); + free(context); +} + +static const JsViewDescriptor view_descriptor = { + .alloc = (JsViewAlloc)variable_item_list_alloc, + .free = (JsViewFree)variable_item_list_free, + .get_view = (JsViewGetView)variable_item_list_get_view, + .custom_make = (JsViewCustomMake)ctx_make, + .custom_destroy = (JsViewCustomDestroy)ctx_destroy, + .add_child = (JsViewAddChild)js_vi_list_add_child, + .reset_children = (JsViewResetChildren)js_vi_list_reset_children, + .prop_cnt = 0, + .props = {}, +}; + +JS_GUI_VIEW_DEF(vi_list, &view_descriptor); diff --git a/applications/system/js_app/packages/fz-sdk/gui/button_menu.d.ts b/applications/system/js_app/packages/fz-sdk/gui/button_menu.d.ts new file mode 100644 index 000000000..3b5dd2359 --- /dev/null +++ b/applications/system/js_app/packages/fz-sdk/gui/button_menu.d.ts @@ -0,0 +1,40 @@ +/** + * Displays a list of buttons. + * + * Sample screenshot of the view + * + * ```js + * let eventLoop = require("event_loop"); + * let gui = require("gui"); + * let buttonMenuView = require("gui/button_menu"); + * ``` + * + * This module depends on the `gui` module, which in turn depends on the + * `event_loop` module, so they _must_ be imported in this order. It is also + * recommended to conceptualize these modules first before using this one. + * + * # Example + * For an example refer to the `gui.js` example script. + * + * # View props + * - `header`: Textual header above the buttons + * + * @version Added in JS SDK 0.4 + * @module + */ + +import type { View, ViewFactory, InputType } from "."; +import type { Contract } from "../event_loop"; + +type Props = { + header: string; +}; + +type Child = { type: "common" | "control", label: string }; + +declare class ButtonMenu extends View { + input: Contract<{ index: number, type: InputType }>; +} +declare class ButtonMenuFactory extends ViewFactory { } +declare const factory: ButtonMenuFactory; +export = factory; diff --git a/applications/system/js_app/packages/fz-sdk/gui/button_panel.d.ts b/applications/system/js_app/packages/fz-sdk/gui/button_panel.d.ts new file mode 100644 index 000000000..dcc956fed --- /dev/null +++ b/applications/system/js_app/packages/fz-sdk/gui/button_panel.d.ts @@ -0,0 +1,49 @@ +/** + * Displays a button matrix. + * + * Sample screenshot of the view + * + * ```js + * let eventLoop = require("event_loop"); + * let gui = require("gui"); + * let buttonPanelView = require("gui/button_panel"); + * ``` + * + * This module depends on the `gui` module, which in turn depends on the + * `event_loop` module, so they _must_ be imported in this order. It is also + * recommended to conceptualize these modules first before using this one. + * + * # Example + * For an example refer to the `gui.js` example script. + * + * # View props + * - `matrixSizeX`: Width of imaginary grid used for navigation + * - `matrixSizeY`: Height of imaginary grid used for navigation + * + * @version Added in JS SDK 0.4 + * @module + */ + +import type { View, ViewFactory, Font, InputType } from "."; +import type { Contract } from "../event_loop"; +import { IconData } from "./icon"; + +type Props = { + matrixSizeX: number, + matrixSizeY: number, +}; + +type Position = { x: number, y: number }; + +type ButtonChild = { type: "button", matrixX: number, matrixY: number, icon: IconData, iconSelected: IconData } & Position; +type LabelChild = { type: "label", font: Font, text: string } & Position; +type IconChild = { type: "icon", icon: IconData }; + +type Child = ButtonChild | LabelChild | IconChild; + +declare class ButtonPanel extends View { + input: Contract<{ index: number, type: InputType }>; +} +declare class ButtonPanelFactory extends ViewFactory { } +declare const factory: ButtonPanelFactory; +export = factory; diff --git a/applications/system/js_app/packages/fz-sdk/gui/icon.d.ts b/applications/system/js_app/packages/fz-sdk/gui/icon.d.ts index 03fc8c53b..8f6e69311 100644 --- a/applications/system/js_app/packages/fz-sdk/gui/icon.d.ts +++ b/applications/system/js_app/packages/fz-sdk/gui/icon.d.ts @@ -1,4 +1,7 @@ -export type BuiltinIcon = "DolphinWait_59x54" | "js_script_10px"; +export type BuiltinIcon = "DolphinWait_59x54" | "js_script_10px" + | "off_19x20" | "off_hover_19x20" + | "power_19x20" | "power_hover_19x20" + | "Settings_14"; export type IconData = symbol & { "__tag__": "icon" }; // introducing a nominal type in a hacky way; the `__tag__` property doesn't really exist. diff --git a/applications/system/js_app/packages/fz-sdk/gui/index.d.ts b/applications/system/js_app/packages/fz-sdk/gui/index.d.ts index 77e5891cd..1c1d14a79 100644 --- a/applications/system/js_app/packages/fz-sdk/gui/index.d.ts +++ b/applications/system/js_app/packages/fz-sdk/gui/index.d.ts @@ -24,24 +24,23 @@ * ### View * In Flipper's terminology, a "View" is a fullscreen design element that * assumes control over the entire viewport and all input events. Different - * types of views are available (not all of which are unfortunately currently - * implemented in JS): + * types of views are available: * | View | Has JS adapter? | * |----------------------|-----------------------| - * | `button_menu` | ❌ | - * | `button_panel` | ❌ | + * | `button_menu` | ✅ | + * | `button_panel` | ✅ | * | `byte_input` | ✅ | * | `dialog_ex` | ✅ (as `dialog`) | * | `empty_screen` | ✅ | * | `file_browser` | ✅ (as `file_picker`) | * | `loading` | ✅ | - * | `menu` | ❌ | - * | `number_input` | ❌ | - * | `popup` | ❌ | + * | `menu` | ✅ | + * | `number_input` | ✅ | + * | `popup` | ✅ | * | `submenu` | ✅ | * | `text_box` | ✅ | * | `text_input` | ✅ | - * | `variable_item_list` | ❌ | + * | `variable_item_list` | ✅ (as `vi_list`) | * | `widget` | ✅ | * * In JS, each view has its own set of properties (or just "props"). The @@ -119,6 +118,9 @@ import type { Contract } from "../event_loop"; +export type Font = "primary" | "secondary" | "keyboard" | "big_numbers"; +export type InputType = "press" | "release" | "short" | "long" | "repeat"; + type Properties = { [K: string]: any }; export declare class View { diff --git a/applications/system/js_app/packages/fz-sdk/gui/menu.d.ts b/applications/system/js_app/packages/fz-sdk/gui/menu.d.ts new file mode 100644 index 000000000..a72fe4b77 --- /dev/null +++ b/applications/system/js_app/packages/fz-sdk/gui/menu.d.ts @@ -0,0 +1,38 @@ +/** + * A list of selectable entries consisting of an icon and a label. + * + * Sample screenshot of the view + * + * ```js + * let eventLoop = require("event_loop"); + * let gui = require("gui"); + * let submenuView = require("gui/menu"); + * ``` + * + * This module depends on the `gui` module, which in turn depends on the + * `event_loop` module, so they _must_ be imported in this order. It is also + * recommended to conceptualize these modules first before using this one. + * + * # Example + * For an example refer to the GUI example. + * + * # View props + * This view doesn't have any props. + * + * @version Added in JS SDK 0.1 + * @version API changed in JS SDK 0.4 + * @module + */ + +import type { View, ViewFactory } from "."; +import type { Contract } from "../event_loop"; +import type { IconData } from "./icon"; + +type Props = {}; +type Child = { icon: IconData, label: string }; +declare class Submenu extends View { + chosen: Contract; +} +declare class SubmenuFactory extends ViewFactory { } +declare const factory: SubmenuFactory; +export = factory; diff --git a/applications/system/js_app/packages/fz-sdk/gui/number_input.d.ts b/applications/system/js_app/packages/fz-sdk/gui/number_input.d.ts new file mode 100644 index 000000000..4882c85fc --- /dev/null +++ b/applications/system/js_app/packages/fz-sdk/gui/number_input.d.ts @@ -0,0 +1,44 @@ +/** + * Displays a number input keyboard. + * + * Sample screenshot of the view + * + * ```js + * let eventLoop = require("event_loop"); + * let gui = require("gui"); + * let numberInputView = require("gui/number_input"); + * ``` + * + * This module depends on the `gui` module, which in turn depends on the + * `event_loop` module, so they _must_ be imported in this order. It is also + * recommended to conceptualize these modules first before using this one. + * + * # Example + * For an example refer to the `gui.js` example script. + * + * # View props + * - `header`: Text displayed at the top of the screen + * - `minValue`: Minimum allowed numeric value + * - `maxValue`: Maximum allowed numeric value + * - `defaultValue`: Default numeric value + * + * @version Added in JS SDK 0.4 + * @module + */ + +import type { View, ViewFactory } from "."; +import type { Contract } from "../event_loop"; + +type Props = { + header: string, + minValue: number, + maxValue: number, + defaultValue: number, +} +type Child = never; +declare class NumberInput extends View { + input: Contract; +} +declare class NumberInputFactory extends ViewFactory { } +declare const factory: NumberInputFactory; +export = factory; diff --git a/applications/system/js_app/packages/fz-sdk/gui/popup.d.ts b/applications/system/js_app/packages/fz-sdk/gui/popup.d.ts new file mode 100644 index 000000000..7f77b79f8 --- /dev/null +++ b/applications/system/js_app/packages/fz-sdk/gui/popup.d.ts @@ -0,0 +1,43 @@ +/** + * Like a Dialog, but with a built-in timer. + * + * Sample screenshot of the view + * + * ```js + * let eventLoop = require("event_loop"); + * let gui = require("gui"); + * let popupView = require("gui/popup"); + * ``` + * + * This module depends on the `gui` module, which in turn depends on the + * `event_loop` module, so they _must_ be imported in this order. It is also + * recommended to conceptualize these modules first before using this one. + * + * # Example + * For an example refer to the `gui.js` example script. + * + * # View props + * - `header`: Text displayed in bold at the top of the screen + * - `text`: Text displayed in the middle of the string + * - `timeout`: Timeout, in milliseconds, after which the event will fire. The + * timer starts counting down when this property is assigned. + * + * @version Added in JS SDK 0.1 + * @module + */ + +import type { View, ViewFactory } from "."; +import type { Contract } from "../event_loop"; + +type Props = { + header: string, + text: string, + timeout: number, +} +type Child = never; +declare class Popup extends View { + timeout: Contract; +} +declare class PopupFactory extends ViewFactory { } +declare const factory: PopupFactory; +export = factory; diff --git a/applications/system/js_app/packages/fz-sdk/gui/submenu.d.ts b/applications/system/js_app/packages/fz-sdk/gui/submenu.d.ts index e73856bee..9fb8d51cf 100644 --- a/applications/system/js_app/packages/fz-sdk/gui/submenu.d.ts +++ b/applications/system/js_app/packages/fz-sdk/gui/submenu.d.ts @@ -18,9 +18,9 @@ * * # View props * - `header`: Text displayed at the top of the screen in bold - * - `items`: Array of selectable textual items * * @version Added in JS SDK 0.1 + * @version API changed in JS SDK 0.4 * @module */ @@ -29,9 +29,8 @@ import type { Contract } from "../event_loop"; type Props = { header: string, - items: string[], }; -type Child = never; +type Child = string; declare class Submenu extends View { chosen: Contract; } diff --git a/applications/system/js_app/packages/fz-sdk/gui/vi_list.d.ts b/applications/system/js_app/packages/fz-sdk/gui/vi_list.d.ts new file mode 100644 index 000000000..73826338c --- /dev/null +++ b/applications/system/js_app/packages/fz-sdk/gui/vi_list.d.ts @@ -0,0 +1,38 @@ +/** + * Displays a list of settings-like variable items. + * + * Sample screenshot of the view + * + * ```js + * let eventLoop = require("event_loop"); + * let gui = require("gui"); + * let viListView = require("gui/vi_list"); + * ``` + * + * This module depends on the `gui` module, which in turn depends on the + * `event_loop` module, so they _must_ be imported in this order. It is also + * recommended to conceptualize these modules first before using this one. + * + * # Example + * For an example refer to the `gui.js` example script. + * + * # View props + * This view doesn't have any props + * + * @version Added in JS SDK 0.4 + * @module + */ + +import type { View, ViewFactory } from "."; +import type { Contract } from "../event_loop"; + +type Props = {}; + +type Child = { label: string, variants: string[] }; + +declare class ViList extends View { + valueUpdate: Contract<{ itemIndex: number, valueIndex: number }>; +} +declare class ViListFactory extends ViewFactory { } +declare const factory: ViListFactory; +export = factory; diff --git a/applications/system/js_app/packages/fz-sdk/package.json b/applications/system/js_app/packages/fz-sdk/package.json index 020f3d45c..aa504008b 100644 --- a/applications/system/js_app/packages/fz-sdk/package.json +++ b/applications/system/js_app/packages/fz-sdk/package.json @@ -24,4 +24,4 @@ "prompts": "^2.4.2", "serialport": "^12.0.0" } -} \ No newline at end of file +} diff --git a/lib/toolbox/SConscript b/lib/toolbox/SConscript index ad368e2a1..eb92c9c00 100644 --- a/lib/toolbox/SConscript +++ b/lib/toolbox/SConscript @@ -29,6 +29,7 @@ env.Append( File("float_tools.h"), File("value_index.h"), File("tar/tar_archive.h"), + File("str_buffer.h"), File("stream/stream.h"), File("stream/file_stream.h"), File("stream/string_stream.h"), diff --git a/lib/toolbox/str_buffer.c b/lib/toolbox/str_buffer.c new file mode 100644 index 000000000..a46a3f27b --- /dev/null +++ b/lib/toolbox/str_buffer.c @@ -0,0 +1,18 @@ +#include "str_buffer.h" + +const char* str_buffer_make_owned_clone(StrBuffer* buffer, const char* str) { + char* owned = strdup(str); + buffer->n_owned_strings++; + buffer->owned_strings = + realloc(buffer->owned_strings, buffer->n_owned_strings * sizeof(const char*)); // -V701 + buffer->owned_strings[buffer->n_owned_strings - 1] = owned; + return owned; +} + +void str_buffer_clear_all_clones(StrBuffer* buffer) { + for(size_t i = 0; i < buffer->n_owned_strings; i++) { + free(buffer->owned_strings[i]); + } + free(buffer->owned_strings); + buffer->owned_strings = NULL; +} diff --git a/lib/toolbox/str_buffer.h b/lib/toolbox/str_buffer.h new file mode 100644 index 000000000..9623065c8 --- /dev/null +++ b/lib/toolbox/str_buffer.h @@ -0,0 +1,47 @@ +/** + * @file str_buffer.h + * + * Allows you to create an owned clone of however many strings that you need, + * then free all of them at once. Essentially the simplest possible append-only + * unindexable array of owned C-style strings. + */ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief StrBuffer instance + * + * Place this struct directly wherever you want, it doesn't have to be `alloc`ed + * and `free`d. + */ +typedef struct { + char** owned_strings; + size_t n_owned_strings; +} StrBuffer; + +/** + * @brief Makes a owned duplicate of the provided string + * + * @param[in] buffer StrBuffer instance + * @param[in] str Input C-style string + * + * @returns C-style string that contains to be valid event after `str` becomes + * invalid + */ +const char* str_buffer_make_owned_clone(StrBuffer* buffer, const char* str); + +/** + * @brief Clears all owned duplicates + * + * @param[in] buffer StrBuffer instance + */ +void str_buffer_clear_all_clones(StrBuffer* buffer); + +#ifdef __cplusplus +} +#endif diff --git a/targets/f18/api_symbols.csv b/targets/f18/api_symbols.csv index 2bbf81e7a..0590a16b8 100644 --- a/targets/f18/api_symbols.csv +++ b/targets/f18/api_symbols.csv @@ -173,6 +173,7 @@ Header,+,lib/toolbox/protocols/protocol_dict.h,, Header,+,lib/toolbox/pulse_protocols/pulse_glue.h,, Header,+,lib/toolbox/saved_struct.h,, Header,+,lib/toolbox/simple_array.h,, +Header,+,lib/toolbox/str_buffer.h,, Header,+,lib/toolbox/stream/buffered_file_stream.h,, Header,+,lib/toolbox/stream/file_stream.h,, Header,+,lib/toolbox/stream/stream.h,, @@ -2632,6 +2633,8 @@ Function,+,storage_simply_remove,_Bool,"Storage*, const char*" Function,+,storage_simply_remove_recursive,_Bool,"Storage*, const char*" Function,-,stpcpy,char*,"char*, const char*" Function,-,stpncpy,char*,"char*, const char*, size_t" +Function,+,str_buffer_clear_all_clones,void,StrBuffer* +Function,+,str_buffer_make_owned_clone,const char*,"StrBuffer*, const char*" Function,+,strcasecmp,int,"const char*, const char*" Function,-,strcasecmp_l,int,"const char*, const char*, locale_t" Function,+,strcasestr,char*,"const char*, const char*" diff --git a/targets/f7/api_symbols.csv b/targets/f7/api_symbols.csv index 2370133d6..1169749cd 100644 --- a/targets/f7/api_symbols.csv +++ b/targets/f7/api_symbols.csv @@ -245,6 +245,7 @@ Header,+,lib/toolbox/protocols/protocol_dict.h,, Header,+,lib/toolbox/pulse_protocols/pulse_glue.h,, Header,+,lib/toolbox/saved_struct.h,, Header,+,lib/toolbox/simple_array.h,, +Header,+,lib/toolbox/str_buffer.h,, Header,+,lib/toolbox/stream/buffered_file_stream.h,, Header,+,lib/toolbox/stream/file_stream.h,, Header,+,lib/toolbox/stream/stream.h,, @@ -3322,6 +3323,8 @@ Function,+,storage_simply_remove,_Bool,"Storage*, const char*" Function,+,storage_simply_remove_recursive,_Bool,"Storage*, const char*" Function,-,stpcpy,char*,"char*, const char*" Function,-,stpncpy,char*,"char*, const char*, size_t" +Function,+,str_buffer_clear_all_clones,void,StrBuffer* +Function,+,str_buffer_make_owned_clone,const char*,"StrBuffer*, const char*" Function,+,strcasecmp,int,"const char*, const char*" Function,-,strcasecmp_l,int,"const char*, const char*, locale_t" Function,+,strcasestr,char*,"const char*, const char*"