diff --git a/applications/system/hid_app/assets/for_11x5.png b/applications/system/hid_app/assets/for_11x5.png new file mode 100644 index 000000000..3ebc4f23c Binary files /dev/null and b/applications/system/hid_app/assets/for_11x5.png differ diff --git a/applications/system/hid_app/views/hid_ptt.c b/applications/system/hid_app/views/hid_ptt.c index 8a28cd292..2696c22c4 100644 --- a/applications/system/hid_app/views/hid_ptt.c +++ b/applications/system/hid_app/views/hid_ptt.c @@ -1,19 +1,38 @@ #include "hid_ptt.h" #include "hid_ptt_menu.h" #include +#include #include #include +#include +#include +#include #include "../hid.h" #include "../views.h" #include "hid_icons.h" #define TAG "HidPushToTalk" +#define HID_PTT_LEFT_HOLD_ANIM_STEP_MS 100U +#define HID_PTT_LEFT_HOLD_ANIM_STEPS 5U + +// Exact home status-bar Bluetooth icon pixels (from assets/icons/StatusBar). +// Bitmap format for canvas_draw_bitmap(): row-major, 1-bit. +// Full compressed frame data including leading 0x00 heatshrink marker, +// copied verbatim from build/f7-firmware-D/assets/compiled/assets_icons.c +static const uint8_t hid_ptt_bluetooth_connected_16x8_bits[] = { + 0x00, 0x04, 0x00, 0x0d, 0x00, 0x16, 0x60, 0x4c, + 0x97, 0x4c, 0x97, 0x16, 0x60, 0x0d, 0x00, 0x04, 0x00, +}; +static const uint8_t hid_ptt_bluetooth_idle_5x8_bits[] = { + 0x00, 0x04, 0x0d, 0x16, 0x0c, 0x0c, 0x16, 0x0d, 0x04, +}; struct HidPushToTalk { View* view; Hid* hid; Widget* help; + FuriTimer* left_hold_timer; }; typedef void (*PushToTalkActionCallback)(HidPushToTalk* hid_ptt); @@ -32,6 +51,7 @@ typedef struct { size_t osIndex; size_t appIndex; size_t window_position; + uint8_t left_hold_progress; PushToTalkActionCallback callback_trigger_mute; PushToTalkActionCallback callback_trigger_camera; PushToTalkActionCallback callback_trigger_hand; @@ -59,6 +79,66 @@ enum HidPushToTalkAppIndex { HidPushToTalkAppIndexSize, }; +static bool hid_ptt_is_zoom_app(size_t app_index) { + return (app_index == HidPushToTalkAppIndexZoom) || + (app_index == HidPushToTalkAppIndexZoomGlobal); +} + +static void hid_ptt_left_hold_timer_callback(void* context) { + furi_assert(context); + HidPushToTalk* hid_ptt = context; + with_view_model( + hid_ptt->view, + HidPushToTalkModel * model, + { + if(model->left_pressed && hid_ptt_is_zoom_app(model->appIndex)) { + if(model->left_hold_progress < HID_PTT_LEFT_HOLD_ANIM_STEPS) { + model->left_hold_progress++; + } + } else { + model->left_hold_progress = 0; + furi_timer_stop(hid_ptt->left_hold_timer); + } + }, + true); +} + +static void hid_ptt_draw_zoom_enter_hint( + Canvas* canvas, + uint8_t x, + uint8_t y, + uint8_t progress, + bool pressed) { + const uint8_t width = 18; + const uint8_t height = 10; + const uint8_t inner_x = x + 1; + const uint8_t inner_y = y + 1; + const uint8_t inner_w = width - 2; + const uint8_t inner_h = height - 1; + + if(progress > 0) { + const uint8_t fill_w = (inner_w * progress) / HID_PTT_LEFT_HOLD_ANIM_STEPS; + if(fill_w > 0) { + canvas_draw_box(canvas, inner_x, inner_y, fill_w, inner_h - 1); + } + } + + for(uint8_t dot_x = x; dot_x < x + width; dot_x += 2) { + canvas_draw_dot(canvas, dot_x, y + height - 1); + } + for(uint8_t dot_y = y + 1; dot_y < y + height; dot_y += 2) { + canvas_draw_dot(canvas, x, dot_y); + canvas_draw_dot(canvas, x + width - 1, dot_y); + } + + UNUSED(pressed); + if(progress > 0) { + canvas_set_color(canvas, ColorWhite); + } + canvas_draw_icon(canvas, x + 4, y + 1, &I_Enter_11x7); + canvas_set_color(canvas, ColorBlack); +} + // meet, zoom static void hid_ptt_start_ptt_meet_zoom(HidPushToTalk* hid_ptt) { hid_hal_keyboard_press(hid_ptt->hid, HID_KEYBOARD_SPACEBAR); @@ -128,9 +208,9 @@ static void hid_ptt_trigger_hand_zoom(HidPushToTalk* hid_ptt) { // zoom global macos static void hid_ptt_trigger_mute_macos_zoom_global(HidPushToTalk* hid_ptt) { hid_hal_keyboard_press( - hid_ptt->hid, KEY_MOD_LEFT_GUI | KEY_MOD_RIGHT_ALT | KEY_MOD_LEFT_SHIFT | HID_KEYBOARD_M); + hid_ptt->hid, KEY_MOD_LEFT_GUI | KEY_MOD_RIGHT_ALT | KEY_MOD_LEFT_SHIFT | HID_KEYBOARD_N); hid_hal_keyboard_release( - hid_ptt->hid, KEY_MOD_LEFT_GUI | KEY_MOD_RIGHT_ALT | KEY_MOD_LEFT_SHIFT | HID_KEYBOARD_M); + hid_ptt->hid, KEY_MOD_LEFT_GUI | KEY_MOD_RIGHT_ALT | KEY_MOD_LEFT_SHIFT | HID_KEYBOARD_N); } static void hid_ptt_trigger_camera_macos_zoom_global(HidPushToTalk* hid_ptt) { @@ -387,6 +467,98 @@ static void hid_ptt_trigger_mute_linux_gather(HidPushToTalk* hid_ptt) { hid_ptt->hid, KEY_MOD_LEFT_CTRL | KEY_MOD_LEFT_SHIFT | HID_KEYBOARD_A); } + static void hid_ptt_populate_help(HidPushToTalk* hid_ptt, uint32_t appIndex) { + widget_reset(hid_ptt->help); + char* app_specific_help = ""; + switch(appIndex) { + case HidPushToTalkAppIndexGoogleMeet: + app_specific_help = + "Google Meet:\n" + "This feature is off by default in your audio settings " + "and may not work for Windows users who use their screen " + "reader. In this situation, the spacebar performs a different action.\n\n"; + break; + case HidPushToTalkAppIndexGoogleMeetGlobal: + app_specific_help = "Google Meet (Global):\n" + "1. Install \"Google Meet - Global Shortcuts\" extension.\n" + "2. Open chrome://extensions/shortcuts.\n" + "3. Set 'Toggle microphone' to Cmd+Ctrl+7 and enable Global.\n" + "4. Set 'Toggle camera' to Cmd+Ctrl+8 and enable Global.\n" + "5. Set 'Raise hand' to Cmd+Ctrl+9 and enable Global.\n\n"; + break; + case HidPushToTalkAppIndexDiscord: + app_specific_help = + "Discord:\n" + "1. Under App Settings, click Voice & Video. Under Input Mode, " + "check the box next to Push to Talk.\n" + "2. Scroll down to SHORTCUT, click Record Keybinder.\n" + "3. Press PTT in the app to bind it." + "4. Go to Keybinds and assign mute button.\n\n"; + break; + case HidPushToTalkAppIndexTeamSpeak: + app_specific_help = "TeamSpeak:\n" + "To make keys working bind them in TeamSpeak settings.\n\n"; + break; + case HidPushToTalkAppIndexTeams: + app_specific_help = + "Teams:\n" + "Go to Settings > Privacy. Make sure Keyboard shortcut to unmute is toggled on.\n\n"; + break; + case HidPushToTalkAppIndexZoomGlobal: + app_specific_help = "Zoom (Global):\n" + "1. Go to Settings > Keyboard Shortcuts.\n" + "2. Find the 'Mute/Unmute' shortcut and click 'Edit'.\n" + "3. Press the Mute button in the app to bind it.\n" + "4. Check global checkbox.\n" + "5. Repeat for video and hand shortcuts.\n" + "6. Long-press < to send Enter key.\n\n"; + break; + case HidPushToTalkAppIndexZoom: + app_specific_help = "Zoom:\n" + "1. Go to Settings > Keyboard Shortcuts.\n" + "2. Find the 'Mute/Unmute' shortcut and click 'Edit'.\n" + "3. Press the Mute button in the app to bind it.\n" + "4. Repeat for video and hand shortcuts.\n" + "5. Long-press < to send Enter key.\n\n"; + break; + } + char* left_button_help = ""; + if(appIndex == HidPushToTalkAppIndexZoom || appIndex == HidPushToTalkAppIndexZoomGlobal) { + left_button_help = "Long-press < sends Enter.\n"; + } + FuriString* msg = furi_string_alloc(); + furi_string_cat_printf( + msg, + "%sGeneral:\n" + "To operate properly flipper microphone " + "status must be in sync with your computer.\n" + "Hold > to change mic status.\n" + "%s" + "Long-press OK in menu to open this help.\n" + "Press BACK to switch mic on/off.\n" + "Hold 'o' for PTT mode (mic will be off once you release 'o')\n" + "Hold BACK to exit.", + app_specific_help, + left_button_help); + widget_add_text_scroll_element(hid_ptt->help, 0, 0, 128, 64, furi_string_get_cstr(msg)); + furi_string_free(msg); + } + + static void hid_ptt_menu_help_callback( + void* context, + uint32_t osIndex, + FuriString* osLabel, + uint32_t appIndex, + FuriString* appLabel) { + UNUSED(osIndex); + UNUSED(osLabel); + UNUSED(appLabel); + furi_assert(context); + HidPushToTalk* hid_ptt = context; + hid_ptt_populate_help(hid_ptt, appIndex); + view_dispatcher_switch_to_view(hid_ptt->hid->view_dispatcher, HidViewPushToTalkHelp); + } + static void hid_ptt_menu_callback( void* context, uint32_t osIndex, @@ -594,67 +766,9 @@ static void hid_ptt_menu_callback( } } - char* app_specific_help = ""; - switch(appIndex) { - case HidPushToTalkAppIndexGoogleMeet: - app_specific_help = - "Google Meet:\n" - "This feature is off by default in your audio settings " - "and may not work for Windows users who use their screen " - "reader. In this situation, the spacebar performs a different action.\n\n"; - break; - case HidPushToTalkAppIndexGoogleMeetGlobal: - app_specific_help = "Google Meet (Global):\n" - "1. Install \"Google Meet - Global Shortcuts\" extension.\n" - "2. Open chrome://extensions/shortcuts.\n" - "3. Set 'Toggle microphone' to Cmd+Ctrl+7 and enable Global.\n" - "4. Set 'Toggle camera' to Cmd+Ctrl+8 and enable Global.\n" - "5. Set 'Raise hand' to Cmd+Ctrl+9 and enable Global.\n\n"; - break; - case HidPushToTalkAppIndexDiscord: - app_specific_help = - "Discord:\n" - "1. Under App Settings, click Voice & Video. Under Input Mode, " - "check the box next to Push to Talk.\n" - "2. Scroll down to SHORTCUT, click Record Keybinder.\n" - "3. Press PTT in the app to bind it." - "4. Go to Keybinds and assign mute button.\n\n"; - break; - case HidPushToTalkAppIndexTeamSpeak: - app_specific_help = "TeamSpeak:\n" - "To make keys working bind them in TeamSpeak settings.\n\n"; - break; - case HidPushToTalkAppIndexTeams: - app_specific_help = - "Teams:\n" - "Go to Settings > Privacy. Make sure Keyboard shortcut to unmute is toggled on.\n\n"; - break; - case HidPushToTalkAppIndexZoomGlobal: - app_specific_help = "Zoom (Global):\n" - "1. Go to Settings > Keyboard Shortcuts.\n" - "2. Find the 'Mute/Unmute' shortcut and click 'Edit'.\n" - "3. Press the Mute button in the app to bind it.\n" - "4. Check global checkbox.\n" - "5. Repeat for video and hand shortcuts.\n\n"; - } - - FuriString* msg = furi_string_alloc(); - furi_string_cat_printf( - msg, - "%sGeneral:\n" - "To operate properly flipper microphone " - "status must be in sync with your computer.\n" - "Hold > to change mic status.\n" - "Hold < to open this help.\n" - "Press BACK to switch mic on/off.\n" - "Hold 'o' for PTT mode (mic will be off once you release 'o')\n" - "Hold BACK to exit.", - app_specific_help); - widget_add_text_scroll_element( - hid_ptt->help, 0, 0, 128, 64, furi_string_get_cstr(msg)); - furi_string_free(msg); }, true); + hid_ptt_populate_help(hid_ptt, appIndex); view_dispatcher_switch_to_view(hid_ptt->hid->view_dispatcher, HidViewPushToTalk); } @@ -673,43 +787,147 @@ static void hid_ptt_draw_text_centered(Canvas* canvas, uint8_t y, FuriString* st furi_string_free(disp_str); } +static void hid_ptt_draw_app_label(Canvas* canvas, uint8_t first_line_y, FuriString* app) { + FuriString* first_line = furi_string_alloc_set(app); + if(canvas_string_width(canvas, furi_string_get_cstr(first_line)) <= canvas_width(canvas)) { + hid_ptt_draw_text_centered(canvas, first_line_y, first_line); + furi_string_free(first_line); + return; + } + + const char* app_cstr = furi_string_get_cstr(app); + const char* split = strrchr(app_cstr, ' '); + if(!split) { + hid_ptt_draw_text_centered(canvas, first_line_y, first_line); + furi_string_free(first_line); + return; + } + + FuriString* second_line = furi_string_alloc(); + furi_string_set_strn(first_line, app_cstr, split - app_cstr); + furi_string_set_str(second_line, split + 1); + + elements_string_fit_width(canvas, first_line, canvas_width(canvas)); + elements_string_fit_width(canvas, second_line, canvas_width(canvas)); + hid_ptt_draw_text_centered(canvas, first_line_y, first_line); + hid_ptt_draw_text_centered(canvas, first_line_y + 10, second_line); + + furi_string_free(second_line); + furi_string_free(first_line); +} + +static void hid_ptt_draw_status_bar(Canvas* canvas, bool show_bt, bool connected) { + char time_str[16]; + DateTime dt; + furi_hal_rtc_get_datetime(&dt); + + uint8_t hour = dt.hour; + if(locale_get_time_format() == LocaleTimeFormat12h) { + if(hour > 12) { + hour -= 12; + } + if(hour == 0) { + hour = 12; + } + } + snprintf(time_str, sizeof(time_str), "%02u:%02u", hour, dt.minute); + + uint8_t battery = furi_hal_power_get_pct(); + if(battery > 100) { + battery = 100; + } + + canvas_set_color(canvas, ColorWhite); + canvas_draw_box(canvas, 0, 0, 64, 13); + canvas_set_color(canvas, ColorBlack); + canvas_draw_rframe(canvas, 0, 0, 64, 13, 1); + canvas_draw_line(canvas, 1, 11, 62, 11); + + if(show_bt) { + if(connected) { + canvas_draw_bitmap(canvas, 2, 2, 16, 8, hid_ptt_bluetooth_connected_16x8_bits); + } else { + canvas_draw_bitmap(canvas, 2, 2, 5, 8, hid_ptt_bluetooth_idle_5x8_bits); + } + } + + const uint8_t battery_x = 48; + const uint8_t battery_y = 3; + const uint8_t battery_w = 13; + const uint8_t battery_h = 6; + canvas_draw_frame(canvas, battery_x, battery_y, battery_w, battery_h); + canvas_draw_box(canvas, battery_x + battery_w, battery_y + 2, 1, 2); + canvas_draw_box(canvas, battery_x + 1, battery_y + 1, ((battery_w - 2) * battery) / 100, 4); + + canvas_set_font(canvas, FontPrimary); + canvas_draw_str_aligned(canvas, 33, 10, AlignCenter, AlignBottom, time_str); +} + static void hid_ptt_draw_callback(Canvas* canvas, void* context) { furi_assert(context); HidPushToTalkModel* model = context; - // Header - canvas_set_font(canvas, FontPrimary); -#ifdef HID_TRANSPORT_BLE - if(model->connected) { - canvas_draw_icon(canvas, 0, 0, &I_Ble_connected_15x15); - } else { - canvas_draw_icon(canvas, 0, 0, &I_Ble_disconnected_15x15); + const bool app_label_needs_two_lines = + canvas_string_width(canvas, furi_string_get_cstr(model->app)) > canvas_width(canvas); + + const uint8_t top_offset = 13; + const uint8_t status_bar_bottom_y = top_offset; + + // For Zoom/Zoom Global, keep helper banner higher to show Enter key hint space + // For other apps, move it down to close the gap + uint8_t helper_top_y = 102; + if((model->appIndex == HidPushToTalkAppIndexZoom || + model->appIndex == HidPushToTalkAppIndexZoomGlobal) && + !app_label_needs_two_lines) { + helper_top_y = 92; } -#endif - - // OS and App labels - canvas_set_font(canvas, FontSecondary); - hid_ptt_draw_text_centered(canvas, 73, model->app); - hid_ptt_draw_text_centered(canvas, 84, model->os); - - // Help label - canvas_draw_icon(canvas, 0, 88, &I_Help_top_64x17); - canvas_draw_line(canvas, 4, 105, 4, 114); - canvas_draw_line(canvas, 63, 105, 63, 114); - canvas_draw_icon(canvas, 7, 107, &I_Hold_15x5); - canvas_draw_icon(canvas, 24, 105, &I_BtnLeft_9x9); - canvas_draw_icon(canvas, 34, 108, &I_for_help_27x5); - canvas_draw_icon(canvas, 0, 115, &I_Help_exit_64x9); - canvas_draw_icon(canvas, 24, 115, &I_BtnBackV_9x9); const uint8_t x_1 = 0; const uint8_t x_2 = x_1 + 19 + 4; const uint8_t x_3 = x_1 + 19 * 2 + 8; - const uint8_t y_1 = 3; + const uint8_t y_1 = top_offset + 3; const uint8_t y_2 = y_1 + 19; const uint8_t y_3 = y_2 + 19; + const uint8_t controls_bottom_y = y_3 + 18; + const uint8_t labels_center_y = (controls_bottom_y + helper_top_y) / 2; + const uint8_t app_label_y = app_label_needs_two_lines ? (controls_bottom_y + 10) : (labels_center_y - 1); + const uint8_t os_label_y = app_label_needs_two_lines ? (app_label_y + 20) : (app_label_y + 11); + + // Header + canvas_set_font(canvas, FontPrimary); +#ifdef HID_TRANSPORT_BLE + hid_ptt_draw_status_bar(canvas, true, model->connected); +#else + hid_ptt_draw_status_bar(canvas, false, false); +#endif + + // OS and App labels + canvas_set_font(canvas, FontSecondary); + if(app_label_needs_two_lines) { + hid_ptt_draw_app_label(canvas, app_label_y, model->app); + } else { + hid_ptt_draw_text_centered(canvas, app_label_y, model->app); + } + hid_ptt_draw_text_centered(canvas, os_label_y, model->os); + + // Help label + canvas_draw_icon(canvas, 0, helper_top_y, &I_Help_top_64x17); + canvas_draw_line(canvas, 4, 109, 4, 118); + canvas_draw_line(canvas, 63, 109, 63, 118); + canvas_draw_icon(canvas, 0, 119, &I_Help_exit_64x9); + canvas_draw_icon(canvas, 24, 119, &I_BtnBackV_9x9); + + // For Zoom/Zoom Global: show "Hold < for Enter" hint + if(model->appIndex == HidPushToTalkAppIndexZoom || + model->appIndex == HidPushToTalkAppIndexZoomGlobal) { + canvas_draw_icon(canvas, 7, 111, &I_Hold_15x5); + canvas_draw_icon(canvas, 24, 109, &I_BtnLeft_9x9); + canvas_draw_icon(canvas, 35, 112, &I_for_11x5); + canvas_draw_icon(canvas, 48, 112, &I_Return_10x7); + } + // Up canvas_draw_icon(canvas, x_2, y_1, &I_Button_18x18); if(model->up_pressed) { @@ -741,6 +959,10 @@ static void hid_ptt_draw_callback(Canvas* canvas, void* context) { } canvas_set_color(canvas, ColorBlack); + if(hid_ptt_is_zoom_app(model->appIndex)) { + hid_ptt_draw_zoom_enter_hint(canvas, x_1, y_2 + 18, model->left_hold_progress, model->left_pressed); + } + // Right / Camera canvas_draw_icon(canvas, x_3, y_2, &I_Button_18x18); if(model->right_pressed) { @@ -756,23 +978,24 @@ static void hid_ptt_draw_callback(Canvas* canvas, void* context) { // Back / Mic const uint8_t x_mic = x_3; - canvas_draw_icon(canvas, x_mic, 0, &I_RoundButtonUnpressed_16x16); + const uint8_t y_mic = status_bar_bottom_y + ((y_2 - status_bar_bottom_y - 16) / 2); + canvas_draw_icon(canvas, x_mic, y_mic, &I_RoundButtonUnpressed_16x16); if(!(!model->muted || (model->ptt_pressed))) { // show muted if(model->mic_pressed) { // canvas_draw_icon(canvas, x_mic + 1, 0, &I_MicrophonePressedCrossed_15x15); - canvas_draw_icon(canvas, x_mic, 0, &I_MicrophonePressedCrossedBtn_16x16); + canvas_draw_icon(canvas, x_mic, y_mic, &I_MicrophonePressedCrossedBtn_16x16); } else { - canvas_draw_icon(canvas, x_mic, 0, &I_MicrophoneCrossed_16x16); + canvas_draw_icon(canvas, x_mic, y_mic, &I_MicrophoneCrossed_16x16); } } else { // show unmuted if(model->mic_pressed) { // canvas_draw_icon(canvas, x_mic + 1, 0, &I_MicrophonePressed_15x15); - canvas_draw_icon(canvas, x_mic, 0, &I_MicrophonePressedBtn_16x16); + canvas_draw_icon(canvas, x_mic, y_mic, &I_MicrophonePressedBtn_16x16); } else { - canvas_draw_icon(canvas, x_mic + 5, 2, &I_Mic_7x11); + canvas_draw_icon(canvas, x_mic + 5, y_mic + 2, &I_Mic_7x11); } } @@ -813,6 +1036,12 @@ static void hid_ptt_process(HidPushToTalk* hid_ptt, InputEvent* event) { hid_hal_consumer_key_press(hid_ptt->hid, HID_CONSUMER_VOLUME_DECREMENT); } else if(event->key == InputKeyLeft) { model->left_pressed = true; + if(hid_ptt_is_zoom_app(model->appIndex)) { + model->left_hold_progress = 0; + furi_timer_start( + hid_ptt->left_hold_timer, + furi_ms_to_ticks(HID_PTT_LEFT_HOLD_ANIM_STEP_MS)); + } } else if(event->key == InputKeyRight) { model->right_pressed = true; } else if(event->key == InputKeyOk) { @@ -836,6 +1065,8 @@ static void hid_ptt_process(HidPushToTalk* hid_ptt, InputEvent* event) { } } else if(event->key == InputKeyLeft) { model->left_pressed = false; + model->left_hold_progress = 0; + furi_timer_stop(hid_ptt->left_hold_timer); } else if(event->key == InputKeyRight) { model->right_pressed = false; @@ -866,10 +1097,15 @@ static void hid_ptt_process(HidPushToTalk* hid_ptt, InputEvent* event) { model->muted = !model->muted; notification_message(hid_ptt->hid->notifications, &sequence_single_vibro); } else if(event->type == InputTypeLong && event->key == InputKeyLeft) { - notification_message(hid_ptt->hid->notifications, &sequence_single_vibro); - model->left_pressed = false; - view_dispatcher_switch_to_view( - hid_ptt->hid->view_dispatcher, HidViewPushToTalkHelp); + if( + model->appIndex == HidPushToTalkAppIndexZoom || + model->appIndex == HidPushToTalkAppIndexZoomGlobal) { + model->left_hold_progress = HID_PTT_LEFT_HOLD_ANIM_STEPS; + furi_timer_stop(hid_ptt->left_hold_timer); + hid_hal_keyboard_press(hid_ptt->hid, HID_KEYBOARD_RETURN); + hid_hal_keyboard_release(hid_ptt->hid, HID_KEYBOARD_RETURN); + notification_message(hid_ptt->hid->notifications, &sequence_single_vibro); + } } //LED if(!model->muted || (model->ptt_pressed)) { @@ -886,6 +1122,7 @@ static bool hid_ptt_input_callback(InputEvent* event, void* context) { HidPushToTalk* hid_ptt = context; bool consumed = false; if(event->type == InputTypeLong && event->key == InputKeyBack) { + furi_timer_stop(hid_ptt->left_hold_timer); hid_hal_keyboard_release_all(hid_ptt->hid); notification_message(hid_ptt->hid->notifications, &sequence_double_vibro); widget_reset(hid_ptt->help); @@ -901,14 +1138,16 @@ View* hid_ptt_get_view(HidPushToTalk* hid_ptt) { return hid_ptt->view; } -static uint32_t hid_ptt_view(void* context) { +static uint32_t hid_ptt_menu_view(void* context) { UNUSED(context); - return HidViewPushToTalk; + return HidViewPushToTalkMenu; } HidPushToTalk* hid_ptt_alloc(Hid* hid) { HidPushToTalk* hid_ptt = malloc(sizeof(HidPushToTalk)); hid_ptt->hid = hid; + hid_ptt->left_hold_timer = + furi_timer_alloc(hid_ptt_left_hold_timer_callback, FuriTimerTypePeriodic, hid_ptt); hid_ptt->view = view_alloc(); view_set_context(hid_ptt->view, hid_ptt); view_allocate_model(hid_ptt->view, ViewModelTypeLocking, sizeof(HidPushToTalkModel)); @@ -921,6 +1160,7 @@ HidPushToTalk* hid_ptt_alloc(Hid* hid) { HidPushToTalkModel * model, { model->muted = true; // assume we're muted + model->left_hold_progress = 0; model->os = furi_string_alloc(); model->app = furi_string_alloc(); }, @@ -1134,14 +1374,17 @@ HidPushToTalk* hid_ptt_alloc(Hid* hid) { hid_ptt); hid_ptt->help = widget_alloc(); - view_set_previous_callback(widget_get_view(hid_ptt->help), hid_ptt_view); + view_set_previous_callback(widget_get_view(hid_ptt->help), hid_ptt_menu_view); view_dispatcher_add_view( hid->view_dispatcher, HidViewPushToTalkHelp, widget_get_view(hid_ptt->help)); + ptt_menu_set_long_ok_callback(hid->hid_ptt_menu, hid_ptt_menu_help_callback, hid_ptt); return hid_ptt; } void hid_ptt_free(HidPushToTalk* hid_ptt) { furi_assert(hid_ptt); + furi_timer_stop(hid_ptt->left_hold_timer); + furi_timer_free(hid_ptt->left_hold_timer); notification_message(hid_ptt->hid->notifications, &sequence_reset_red); with_view_model( hid_ptt->view, diff --git a/applications/system/hid_app/views/hid_ptt_menu.c b/applications/system/hid_app/views/hid_ptt_menu.c index 074c85ba7..7072eff57 100644 --- a/applications/system/hid_app/views/hid_ptt_menu.c +++ b/applications/system/hid_app/views/hid_ptt_menu.c @@ -2,14 +2,22 @@ #include "hid_ptt.h" #include #include +#include #include "../hid.h" #include "../views.h" +#include "hid_icons.h" + #define TAG "HidPushToTalkMenu" +#define PTT_MENU_HELP_HINT_DELAY_MS 5000U +#define PTT_MENU_HINT_TIMER_PERIOD_MS 150U struct HidPushToTalkMenu { View* view; Hid* hid; + PushToTalkMenuLongOkCallback long_ok_callback; + void* long_ok_callback_context; + FuriTimer* hint_timer; }; typedef struct { @@ -60,8 +68,71 @@ typedef struct { size_t window_position; PushToTalkMenuList* lists; int lists_count; + uint32_t last_interaction_tick; + size_t hint_list_position; + size_t hint_item_position; + bool hint_visible; + uint16_t hint_scroll_tick; } HidPushToTalkMenuModel; +static void hid_ptt_menu_mark_interaction(HidPushToTalkMenu* hid_ptt_menu) { + with_view_model( + hid_ptt_menu->view, + HidPushToTalkMenuModel * model, + { + model->last_interaction_tick = furi_get_tick(); + model->hint_list_position = model->list_position; + model->hint_item_position = model->position; + model->hint_visible = false; + model->hint_scroll_tick = 0; + }, + true); +} + +static void hid_ptt_menu_hint_timer_callback(void* context) { + furi_assert(context); + HidPushToTalkMenu* hid_ptt_menu = context; + with_view_model( + hid_ptt_menu->view, + HidPushToTalkMenuModel * model, + { + const uint32_t now = furi_get_tick(); + const bool selection_changed = + (model->list_position != model->hint_list_position) || + (model->position != model->hint_item_position); + + if(selection_changed) { + model->hint_list_position = model->list_position; + model->hint_item_position = model->position; + model->last_interaction_tick = now; + model->hint_visible = false; + model->hint_scroll_tick = 0; + } else if(!model->hint_visible) { + if((now - model->last_interaction_tick) >= PTT_MENU_HELP_HINT_DELAY_MS) { + model->hint_visible = true; + model->hint_scroll_tick = 0; + } + } else { + model->hint_scroll_tick++; + } + }, + true); +} + +static void hid_ptt_menu_enter_callback(void* context) { + furi_assert(context); + HidPushToTalkMenu* hid_ptt_menu = context; + hid_ptt_menu_mark_interaction(hid_ptt_menu); + furi_timer_start( + hid_ptt_menu->hint_timer, furi_ms_to_ticks(PTT_MENU_HINT_TIMER_PERIOD_MS)); +} + +static void hid_ptt_menu_exit_callback(void* context) { + furi_assert(context); + HidPushToTalkMenu* hid_ptt_menu = context; + furi_timer_stop(hid_ptt_menu->hint_timer); +} + static void hid_ptt_menu_draw_list(Canvas* canvas, void* context, const PushToTalkMenuItemArray_t items) { furi_assert(context); @@ -94,13 +165,44 @@ static void FuriString* disp_str; disp_str = furi_string_alloc_set(PushToTalkMenuItemArray_cref(it)->label); - elements_string_fit_width(canvas, disp_str, item_width - (6 * 2)); + const int32_t text_y = y_offset + (item_position * item_height) + item_height - 4; + const int32_t row_left_x = 6; + const int32_t row_right_x = item_width - 2; + const size_t row_width = row_right_x - row_left_x; - canvas_draw_str( - canvas, - 6, - y_offset + (item_position * item_height) + item_height - 4, - furi_string_get_cstr(disp_str)); + if((position == model->position) && model->hint_visible) { + const char* hint_prefix = " | Long press"; + const char* hint_suffix = "for help"; + const int32_t icon_y = text_y - 8; + + const size_t label_w = canvas_string_width(canvas, furi_string_get_cstr(disp_str)); + const size_t prefix_w = canvas_string_width(canvas, hint_prefix); + const size_t icon_w = 9; + const size_t suffix_w = canvas_string_width(canvas, hint_suffix); + + const size_t group_w = label_w + 2 + prefix_w + 2 + icon_w + 2 + suffix_w; + + int32_t scroll_offset = 0; + if(group_w > row_width) { + const size_t overflow = group_w - row_width; + const size_t cycle = overflow * 2; + const size_t step = cycle ? (model->hint_scroll_tick % cycle) : 0; + scroll_offset = (step <= overflow) ? step : (cycle - step); + } + + const int32_t draw_x = row_left_x - scroll_offset; + canvas_draw_str(canvas, draw_x, text_y, furi_string_get_cstr(disp_str)); + + int32_t hint_x = draw_x + label_w + 2; + canvas_draw_str(canvas, hint_x, text_y, hint_prefix); + hint_x += prefix_w + 2; + canvas_draw_icon(canvas, hint_x, icon_y, &I_Ok_btn_9x9); + hint_x += icon_w + 2; + canvas_draw_str(canvas, hint_x, text_y, hint_suffix); + } else { + elements_string_fit_width(canvas, disp_str, item_width - (6 * 2)); + canvas_draw_str(canvas, row_left_x, text_y, furi_string_get_cstr(disp_str)); + } furi_string_free(disp_str); } @@ -338,6 +440,39 @@ void ptt_menu_process_ok(HidPushToTalkMenu* hid_ptt_menu) { } } +void ptt_menu_process_long_ok(HidPushToTalkMenu* hid_ptt_menu) { + PushToTalkMenuList* list = NULL; + PushToTalkMenuItem* item = NULL; + with_view_model( + hid_ptt_menu->view, + HidPushToTalkMenuModel * model, + { + list = &model->lists[model->list_position]; + const size_t items_size = PushToTalkMenuItemArray_size(list->items); + if(model->position < items_size) { + item = PushToTalkMenuItemArray_get(list->items, model->position); + } + }, + false); + if(item && list && hid_ptt_menu->long_ok_callback) { + hid_ptt_menu->long_ok_callback( + hid_ptt_menu->long_ok_callback_context, + list->index, + list->label, + item->index, + item->label); + } +} + +void ptt_menu_set_long_ok_callback( + HidPushToTalkMenu* hid_ptt_menu, + PushToTalkMenuLongOkCallback callback, + void* callback_context) { + furi_assert(hid_ptt_menu); + hid_ptt_menu->long_ok_callback = callback; + hid_ptt_menu->long_ok_callback_context = callback_context; +} + static bool hid_ptt_menu_input_callback(InputEvent* event, void* context) { furi_assert(context); HidPushToTalkMenu* hid_ptt_menu = context; @@ -375,7 +510,15 @@ static bool hid_ptt_menu_input_callback(InputEvent* event, void* context) { consumed = true; ptt_menu_process_down(hid_ptt_menu); } + } else if(event->type == InputTypeLong && event->key == InputKeyOk) { + consumed = true; + ptt_menu_process_long_ok(hid_ptt_menu); } + + if(event->type != InputTypeRelease) { + hid_ptt_menu_mark_interaction(hid_ptt_menu); + } + return consumed; } @@ -392,6 +535,11 @@ HidPushToTalkMenu* hid_ptt_menu_alloc(Hid* hid) { view_allocate_model(hid_ptt_menu->view, ViewModelTypeLocking, sizeof(HidPushToTalkMenuModel)); view_set_draw_callback(hid_ptt_menu->view, hid_ptt_menu_draw_callback); view_set_input_callback(hid_ptt_menu->view, hid_ptt_menu_input_callback); + view_set_enter_callback(hid_ptt_menu->view, hid_ptt_menu_enter_callback); + view_set_exit_callback(hid_ptt_menu->view, hid_ptt_menu_exit_callback); + + hid_ptt_menu->hint_timer = + furi_timer_alloc(hid_ptt_menu_hint_timer_callback, FuriTimerTypePeriodic, hid_ptt_menu); with_view_model( hid_ptt_menu->view, @@ -400,6 +548,11 @@ HidPushToTalkMenu* hid_ptt_menu_alloc(Hid* hid) { model->lists_count = 0; model->position = 0; model->window_position = 0; + model->last_interaction_tick = furi_get_tick(); + model->hint_list_position = 0; + model->hint_item_position = 0; + model->hint_visible = false; + model->hint_scroll_tick = 0; }, true); return hid_ptt_menu; @@ -407,6 +560,8 @@ HidPushToTalkMenu* hid_ptt_menu_alloc(Hid* hid) { void hid_ptt_menu_free(HidPushToTalkMenu* hid_ptt_menu) { furi_assert(hid_ptt_menu); + furi_timer_stop(hid_ptt_menu->hint_timer); + furi_timer_free(hid_ptt_menu->hint_timer); with_view_model( hid_ptt_menu->view, HidPushToTalkMenuModel * model, diff --git a/applications/system/hid_app/views/hid_ptt_menu.h b/applications/system/hid_app/views/hid_ptt_menu.h index 2defd0569..df6fcb015 100644 --- a/applications/system/hid_app/views/hid_ptt_menu.h +++ b/applications/system/hid_app/views/hid_ptt_menu.h @@ -12,6 +12,13 @@ typedef void (*PushToTalkMenuItemCallback)( uint32_t itemIndex, FuriString* itemLabel); + typedef void (*PushToTalkMenuLongOkCallback)( + void* context, + uint32_t listIndex, + FuriString* listLabel, + uint32_t itemIndex, + FuriString* itemLabel); + HidPushToTalkMenu* hid_ptt_menu_alloc(Hid* bt_hid); void hid_ptt_menu_free(HidPushToTalkMenu* hid_ptt_menu); @@ -27,3 +34,8 @@ void ptt_menu_add_item_to_list( void* callback_context); void ptt_menu_add_list(HidPushToTalkMenu* hid_ptt_menu, const char* label, uint32_t index); + +void ptt_menu_set_long_ok_callback( + HidPushToTalkMenu* hid_ptt_menu, + PushToTalkMenuLongOkCallback callback, + void* callback_context);