Forráskód Böngészése

Merge quac from https://github.com/rdefeo/quac

# Conflicts:
#	quac/CHANGELOG.md
Willy-JL 10 hónapja
szülő
commit
0cda32d968

+ 10 - 7
quac/README.md

@@ -21,7 +21,7 @@ The app does not provide any recording functionality - you must use the existing
 * [Playback of rfid, sub-ghz, IR, NFC, iButton signals](README.md#signal-playback)
 * [Easy navigation](README.md#navigation--controls)
 * [Flexible signal organization](README.md#signal-organization) - utilizing the SDcard filesystem
-* [In-app file management](README.md#action-settings) - rename, delete, import
+* [In-app file management](README.md#action-settings) - rename, delete, import, import link
 * [Playlist support](README.md#playlists)
 * [Flexible naming/sorting, hidden file/folder support](README.md#sorting-and-naming)
 * [Customizable UI](README.md#application-settings)
@@ -73,12 +73,15 @@ Here's an example file layout for the screenshots above:
 
 Long pressing the `Right` button will launch a settings menu for the currently selected action. This provides the following options:
 
-* Rename: Allows you to rename the selected item. Useful for changing sorting order. The file extension is preserved on signal files. **Note: folder renaming is broken right now**
-* Delete: Deletes files and folders - folders must be empty
-* Import Here: Launches file browser to let you select a signal file from anywhere on the SDcard and then copies it to the current folder.
+* **Rename**: Allows you to rename the selected item. Useful for changing sorting order. The file extension is preserved on signal files. **Note: folder renaming is broken right now**
+* **Delete**: Deletes files and folders - folders must be empty
+* **Import Here**: Launches file browser to let you select a signal file from anywhere on the SDcard and then copies it to the current folder.
   * When importing an IR file, you are prompted to select which IR command to import. This individual command is imported as it's own `.ir` file into the current location. You can also select `* IMPORT ALL *` to, well, import all commands.
   * If an Import fails, the Flipperzero will flash red and buzz - this may be caused by a duplicate filename (i.e. that file/IR command already exists in the current folder) or the target file can not be read.
-* Create Group: Prompts for the name of a new folder that will be created at that point in the folder structure.
+* **Import Link Here**: Similar to Link Here, but instead of copying the file, it will generate a Quac Link file (`.ql`) which contains the path of the selected file.
+  * At this time, you can not create a link to an IR file, as this would need to also include a reference to the specific signal within the target file.
+  * You can not create a link to a Quac Link file.
+* **Create Group**: Prompts for the name of a new folder that will be created at that point in the folder structure.
 
 ## Playlists
 
@@ -128,7 +131,7 @@ The settings menu will appear as the last item when you are viewing the "root" d
 * iButton Duration: Changes the length of time a iButton signal is transmitted. Within playlists, this can be overridden per `.ibtn` file.
 * IR Ext Ant: Whether to use the external device for IR signals. If enabled, but no external IR device is attached to TX, then the internal IR device will be used.
 * Show Hidden: Will display files and folders that start with a period (`.`)
-* About: Application info
+* About: Application info and version
 
 ## Troubleshooting
 
@@ -140,6 +143,6 @@ For some errors, Quac! will show an error message on screen. In other cases, it
 
 ## Building
 
-This app is currently built with [ufbt](https://github.com/flipperdevices/flipperzero-ufbt), intended for the stock firmware. I have not tested this on other firmwares. The `.fap` file can be found in the Releases section on the right.
+This app is currently built with [ufbt](https://github.com/flipperdevices/flipperzero-ufbt), intended for the official firmware. I have not tested this on other firmwares. The `.fap` file can be found in the Releases section on the right.
 
 <a href="https://www.buymeacoffee.com/rdefeo" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>

+ 52 - 6
quac/actions/action.c

@@ -3,22 +3,68 @@
 #include "item.h"
 #include "action_i.h"
 
+#define MAX_FILE_LEN (size_t)256
+
+void action_ql_resolve(
+    void* context,
+    const FuriString* action_path,
+    FuriString* new_path,
+    FuriString* error) {
+    UNUSED(error);
+    App* app = (App*)context;
+    File* file_link = storage_file_alloc(app->storage);
+    do {
+        if(!storage_file_open(
+               file_link, furi_string_get_cstr(action_path), FSAM_READ, FSOM_OPEN_EXISTING)) {
+            ACTION_SET_ERROR(
+                "Quac Link: Failed to open file %s", furi_string_get_cstr(action_path));
+            break;
+        }
+        char buffer[MAX_FILE_LEN]; // long enough?
+        size_t bytes_read = storage_file_read(file_link, buffer, MAX_FILE_LEN);
+        if(bytes_read == 0) {
+            ACTION_SET_ERROR(
+                "Quac Link: Error reading link file %s", furi_string_get_cstr(action_path));
+            break;
+        }
+        if(!storage_file_exists(app->storage, buffer)) {
+            ACTION_SET_ERROR("Quac Link: Linked file does not exist! %s", buffer);
+            break;
+        }
+        furi_string_set_strn(new_path, buffer, bytes_read);
+    } while(false);
+    storage_file_close(file_link);
+    storage_file_free(file_link);
+}
+
 void action_tx(void* context, Item* item, FuriString* error) {
     // FURI_LOG_I(TAG, "action_run: %s : %s", furi_string_get_cstr(item->name), item->ext);
 
+    FuriString* path = furi_string_alloc_set(item->path);
+    if(item->is_link) {
+        // This is a Quac link, open the file and pull the real filename
+        action_ql_resolve(context, item->path, path, error);
+        if(furi_string_size(error)) {
+            furi_string_free(path);
+            return;
+        }
+        FURI_LOG_I(TAG, "Resolved Quac link file to: %s", furi_string_get_cstr(path));
+    }
+
     if(!strcmp(item->ext, ".sub")) {
-        action_subghz_tx(context, item->path, error);
+        action_subghz_tx(context, path, error);
     } else if(!strcmp(item->ext, ".ir")) {
-        action_ir_tx(context, item->path, error);
+        action_ir_tx(context, path, error);
     } else if(!strcmp(item->ext, ".rfid")) {
-        action_rfid_tx(context, item->path, error);
+        action_rfid_tx(context, path, error);
     } else if(!strcmp(item->ext, ".nfc")) {
-        action_nfc_tx(context, item->path, error);
+        action_nfc_tx(context, path, error);
     } else if(!strcmp(item->ext, ".ibtn")) {
-        action_ibutton_tx(context, item->path, error);
+        action_ibutton_tx(context, path, error);
     } else if(!strcmp(item->ext, ".qpl")) {
-        action_qpl_tx(context, item->path, error);
+        action_qpl_tx(context, path, error);
     } else {
         FURI_LOG_E(TAG, "Unknown item file type! %s", item->ext);
     }
+    furi_string_free(path);
 }

+ 1 - 1
quac/actions/action_i.h

@@ -10,4 +10,4 @@ void action_rfid_tx(void* context, const FuriString* action_path, FuriString* er
 void action_ir_tx(void* context, const FuriString* action_path, FuriString* error);
 void action_nfc_tx(void* context, const FuriString* action_path, FuriString* error);
 void action_ibutton_tx(void* context, const FuriString* action_path, FuriString* error);
-void action_qpl_tx(void* context, const FuriString* action_path, FuriString* error);
+void action_qpl_tx(void* context, const FuriString* action_path, FuriString* error);

+ 28 - 9
quac/item.c

@@ -92,28 +92,27 @@ ItemsView* item_get_items_view_from_path(void* context, const FuriString* input_
     ItemArray_init(iview->items);
     for(FileArray_it(iter, flist); !FileArray_end_p(iter); FileArray_next(iter)) {
         path = *FileArray_ref(iter);
-        const char* found_path = furi_string_get_cstr(path);
 
         Item* item = ItemArray_push_new(iview->items);
+        item->is_link = false;
+        item->name = furi_string_alloc();
 
         FileInfo fileinfo;
-        if(storage_common_stat(app->storage, found_path, &fileinfo) == FSE_OK &&
+        if(storage_common_stat(app->storage, furi_string_get_cstr(path), &fileinfo) == FSE_OK &&
            file_info_is_dir(&fileinfo)) {
             item->type = Item_Group;
+            path_extract_filename(path, item->name, false);
         } else {
-            // Action files have extensions, so item->ext starts with '.'
+            // Action files have extensions, which determine their type
             item->ext[0] = 0;
-            path_extract_extension(path, item->ext, MAX_EXT_LEN);
+            item_path_extract_filename(path, item->name, &(item->ext), &(item->is_link));
             item->type = item_get_item_type_from_extension(item->ext);
         }
 
-        item->name = furi_string_alloc();
-        path_extract_filename_no_ext(found_path, item->name);
         // FURI_LOG_I(TAG, "Basename: %s", furi_string_get_cstr(item->name));
         item_prettify_name(item->name);
 
-        item->path = furi_string_alloc();
-        furi_string_set(item->path, path);
+        item->path = furi_string_alloc_set(path);
         // FURI_LOG_I(TAG, "Path: %s", furi_string_get_cstr(item->path));
     }
 
@@ -169,4 +168,24 @@ ItemType item_get_item_type_from_extension(const char* ext) {
         type = Item_Playlist;
     }
     return type;
-}
+}
+
+void item_path_extract_filename(
+    FuriString* path,
+    FuriString* name,
+    char (*ext)[MAX_EXT_LEN],
+    bool* is_link) {
+    furi_check(ext);
+    furi_check(is_link);
+
+    FuriString* temp = furi_string_alloc_set(path);
+    *is_link = furi_string_end_withi_str(temp, ".ql");
+    if(*is_link) {
+        furi_string_left(temp, furi_string_size(temp) - 3);
+    }
+
+    path_extract_extension(temp, *ext, MAX_EXT_LEN);
+    path_extract_filename(temp, name, true);
+
+    furi_string_free(temp);
+}

+ 12 - 3
quac/item.h

@@ -3,8 +3,8 @@
 #include <m-array.h>
 
 // Max length of a filename, final path element only
-#define MAX_NAME_LEN 64
-#define MAX_EXT_LEN  6
+#define MAX_NAME_LEN (size_t)64
+#define MAX_EXT_LEN  (size_t)6
 
 /** Defines an individual item action or item group. Each object contains
  * the relevant file and type information needed to both render correctly
@@ -28,7 +28,8 @@ typedef struct Item {
     ItemType type;
     FuriString* name;
     FuriString* path;
-    char ext[MAX_EXT_LEN + 1];
+    char ext[MAX_EXT_LEN];
+    bool is_link;
 } Item;
 
 ARRAY_DEF(ItemArray, Item, M_POD_OPLIST);
@@ -64,3 +65,11 @@ void item_prettify_name(FuriString* name);
  * @param   ext     File extension
 */
 ItemType item_get_item_type_from_extension(const char* ext);
+
+/** Extract filename and extension from path. Check if path is a link file
+*/
+void item_path_extract_filename(
+    FuriString* path,
+    FuriString* name,
+    char (*ext)[MAX_EXT_LEN],
+    bool* is_link);

+ 10 - 3
quac/scenes/scene_action_rename.c

@@ -28,7 +28,9 @@ void scene_action_rename_on_enter(void* context) {
     text_input_set_header_text(text, "Enter new name:");
 
     FuriString* file_name = furi_string_alloc();
-    path_extract_filename_no_ext(furi_string_get_cstr(item->path), file_name);
+    char ext[MAX_EXT_LEN] = {0};
+    bool is_link;
+    item_path_extract_filename(item->path, file_name, &ext, &is_link);
     strncpy(app->temp_cstr, furi_string_get_cstr(file_name), MAX_NAME_LEN);
 
     text_input_set_result_callback(
@@ -44,14 +46,16 @@ bool scene_action_rename_on_event(void* context, SceneManagerEvent event) {
     if(event.type == SceneManagerEventTypeCustom) {
         if(event.event == SceneActionRenameEvent) {
             // FURI_LOG_I(TAG, "Attempting rename to %s", app->temp_cstr);
-            if(!strcmp(app->temp_cstr, "")) {
+            if(!strcmp(app->temp_cstr, "") || !path_contains_only_ascii(app->temp_cstr)) {
                 return false;
             }
             Item* item = ItemArray_get(app->items_view->items, app->selected_item);
             const char* old_path = furi_string_get_cstr(item->path);
 
             FuriString* file_name = furi_string_alloc();
-            path_extract_filename(item->path, file_name, true);
+            char ext[MAX_EXT_LEN] = {0};
+            bool is_link;
+            item_path_extract_filename(item->path, file_name, &ext, &is_link);
             // FURI_LOG_I(TAG, "Original name is %s", furi_string_get_cstr(file_name));
             if(!furi_string_cmp_str(file_name, app->temp_cstr)) {
                 FURI_LOG_W(TAG, "Rename: File names are the same!");
@@ -64,6 +68,9 @@ bool scene_action_rename_on_event(void* context, SceneManagerEvent event) {
             path_extract_dirname(old_path, dir_name);
             FuriString* new_path = furi_string_alloc_printf(
                 "%s/%s%s", furi_string_get_cstr(dir_name), app->temp_cstr, item->ext);
+            if(is_link) {
+                furi_string_cat_str(new_path, ".ql");
+            }
 
             FURI_LOG_I(TAG, "Rename: %s to %s", old_path, furi_string_get_cstr(new_path));
             FS_Error fs_result =

+ 79 - 3
quac/scenes/scene_action_settings.c

@@ -16,6 +16,7 @@ typedef enum {
     ActionSettingsRename, // Rename file or folder
     ActionSettingsDelete, // Delete file or folder on SDcard
     ActionSettingsImport, // Copy a remote file into "current" folder
+    ActionSettingsImportLink, // Create a link to a remote file into "current" folder
     ActionSettingsCreateGroup, // Create new empty folder in "current" folder
     ActionSettingsCreatePlaylist, // Turn this folder into a playlist
     ActionSettingsAddToPlaylist, // Append a remote file to this playlist
@@ -83,6 +84,8 @@ static bool scene_action_settings_import_file_browser_callback(
         memcpy(*icon, icon_get_frame_data(&I_IR_10px, 0), 32);
     } else if(!strcmp(ext, ".nfc")) {
         memcpy(*icon, icon_get_frame_data(&I_NFC_10px, 0), 32);
+    } else if(!strcmp(ext, ".ibtn")) {
+        memcpy(*icon, icon_get_frame_data(&I_iButton_10px, 0), 32);
     } else if(!strcmp(ext, ".qpl")) {
         memcpy(*icon, icon_get_frame_data(&I_Playlist_10px, 0), 32);
     } else {
@@ -132,7 +135,6 @@ bool scene_action_settings_import(App* app) {
 
     if(dialog_file_browser_show(app->dialog, app->temp_str, app->temp_str, &fb_options)) {
         // FURI_LOG_I(TAG, "Selected file is %s", furi_string_get_cstr(app->temp_str));
-        // TODO: this should be a method
         FuriString* file_name = furi_string_alloc();
         path_extract_filename(app->temp_str, file_name, false);
         // FURI_LOG_I(TAG, "Importing file %s", furi_string_get_cstr(file_name));
@@ -198,6 +200,8 @@ void scene_action_settings_on_enter(void* context) {
 
     submenu_add_item(
         menu, "Import Here", ActionSettingsImport, scene_action_settings_callback, app);
+    submenu_add_item(
+        menu, "Import Link Here", ActionSettingsImportLink, scene_action_settings_callback, app);
     submenu_add_item(
         menu, "Create Group", ActionSettingsCreateGroup, scene_action_settings_callback, app);
 
@@ -219,7 +223,7 @@ bool scene_action_settings_on_event(void* context, SceneManagerEvent event) {
                 scene_manager_previous_scene(app->scene_manager);
             }
             break;
-        case ActionSettingsImport:
+        case ActionSettingsImport: {
             consumed = true;
             // get the filename to import
             FuriString* import_file = scene_action_get_file_to_import_alloc(app);
@@ -285,7 +289,79 @@ bool scene_action_settings_on_event(void* context, SceneManagerEvent event) {
             // if(scene_action_settings_import(app)) {
             //     scene_manager_previous_scene(app->scene_manager);
             // }
-            break;
+        } break;
+        case ActionSettingsImportLink: {
+            consumed = true;
+            // get the filename to import as a link
+            FuriString* import_file = scene_action_get_file_to_import_alloc(app);
+            if(import_file) {
+                FURI_LOG_I(TAG, "Importing as link %s", furi_string_get_cstr(import_file));
+                char ext[MAX_EXT_LEN] = {0};
+
+                path_extract_extension(import_file, ext, MAX_EXT_LEN);
+                if(!strcmp(ext, ".ir")) {
+                    dialog_message_show_storage_error(
+                        app->dialog, "Can't import IR file as link at this time");
+                } else if(!strcmp(ext, ".ql")) {
+                    FURI_LOG_E(TAG, "Can't import link file as a link!");
+                    dialog_message_show_storage_error(
+                        app->dialog, "Can't import link file as a link!");
+                } else {
+                    FuriString* current_path = furi_string_alloc();
+                    if(app->selected_item != EMPTY_ACTION_INDEX) {
+                        Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+                        path_extract_dirname(furi_string_get_cstr(item->path), current_path);
+                    } else {
+                        furi_string_set(current_path, app->items_view->path);
+                    }
+                    FuriString* file_name = furi_string_alloc();
+                    path_extract_filename(import_file, file_name, false);
+
+                    FuriString* full_path;
+                    full_path = furi_string_alloc_printf(
+                        "%s/%s.ql", // path/filename.ext.ql
+                        furi_string_get_cstr(current_path),
+                        furi_string_get_cstr(file_name));
+                    FURI_LOG_I(
+                        TAG,
+                        "Copy as link: %s to %s",
+                        furi_string_get_cstr(import_file),
+                        furi_string_get_cstr(full_path));
+
+                    File* file_link = storage_file_alloc(app->storage);
+                    if(storage_file_open(
+                           file_link,
+                           furi_string_get_cstr(full_path),
+                           FSAM_WRITE,
+                           FSOM_CREATE_NEW)) {
+                        const char* cimport_file = furi_string_get_cstr(import_file);
+                        size_t bytes_written =
+                            storage_file_write(file_link, cimport_file, strlen(cimport_file));
+                        if(bytes_written != strlen(cimport_file)) {
+                            FURI_LOG_E(
+                                TAG,
+                                "Copy as link failure: incorrect bytes written. Expected %d, wrote %d",
+                                strlen(cimport_file),
+                                bytes_written);
+                        }
+                    } else {
+                        dialog_message_show_storage_error(app->dialog, "Error writing link file!");
+                        FURI_LOG_E(
+                            TAG,
+                            "Copy file as link failed! File %s already exists",
+                            furi_string_get_cstr(full_path));
+                    }
+                    storage_file_close(file_link);
+                    storage_file_free(file_link);
+
+                    furi_string_free(file_name);
+                    furi_string_free(full_path);
+                }
+                furi_string_free(import_file);
+            } else {
+                scene_manager_previous_scene(app->scene_manager);
+            }
+        } break;
         case ActionSettingsCreateGroup:
             consumed = true;
             scene_manager_next_scene(app->scene_manager, QScene_ActionCreateGroup);

+ 6 - 3
quac/scenes/scene_items.c

@@ -67,9 +67,12 @@ void scene_items_on_enter(void* context) {
         int32_t index = 0;
         for(ItemArray_it(iter, items_view->items); !ItemArray_end_p(iter);
             ItemArray_next(iter), ++index) {
-            const char* label = furi_string_get_cstr(ItemArray_cref(iter)->name);
-            ActionMenuItemType type = ItemToMenuItem[ItemArray_cref(iter)->type];
-            action_menu_add_item(menu, label, index, scene_items_item_callback, type, app);
+            const Item* item = ItemArray_cref(iter);
+            const char* label = furi_string_get_cstr(item->name);
+            ActionMenuItemType type = ItemToMenuItem[item->type];
+            ActionMenuItem* menu_item =
+                action_menu_add_item(menu, label, index, scene_items_item_callback, type, app);
+            action_menu_item_set_link(menu_item, item->is_link);
         }
     } else {
         FURI_LOG_W(TAG, "No items for: %s", furi_string_get_cstr(items_view->path));

+ 113 - 13
quac/views/action_menu.c

@@ -20,6 +20,9 @@
 #define ITEMS_PER_SCREEN_LANDSCAPE 3
 #define ITEMS_PER_SCREEN_PORTRAIT 6
 
+#define SCROLL_INTERVAL 333
+#define SCROLL_DELAY 2
+
 static const Icon* ActionMenuIcons[] = {
     [ActionMenuItemTypeSubGHz] = &I_SubGHz_10px,
     [ActionMenuItemTypeRFID] = &I_RFID_10px,
@@ -38,6 +41,7 @@ struct ActionMenuItem {
     ActionMenuItemCallback callback;
     ActionMenuItemType type;
     void* callback_context;
+    bool is_link;
 };
 
 ARRAY_DEF(ActionMenuItemArray, ActionMenuItem, M_POD_OPLIST);
@@ -45,6 +49,7 @@ ARRAY_DEF(ActionMenuItemArray, ActionMenuItem, M_POD_OPLIST);
 
 struct ActionMenu {
     View* view;
+    FuriTimer* scroll_timer;
 };
 
 typedef struct {
@@ -55,8 +60,40 @@ typedef struct {
     ActionMenuLayout layout;
     bool show_icons;
     bool show_headers;
+    size_t scroll_counter;
 } ActionMenuModel;
 
+// Returns the adjusted scroll counter, accounting for the initial pause/delay
+// when an item is first selected
+size_t get_adjusted_scroll_counter(bool selected, size_t scroll_counter) {
+    if(selected) {
+        if(scroll_counter < SCROLL_DELAY) {
+            return 0;
+        }
+        return scroll_counter - SCROLL_DELAY;
+    }
+    return 0;
+}
+
+static void action_menu_scroll_timer_callback(void* context) {
+    furi_check(context);
+    ActionMenu* menu = context;
+    with_view_model(menu->view, ActionMenuModel * model, { model->scroll_counter++; }, true);
+}
+
+static void action_menu_view_enter_callback(void* context) {
+    furi_check(context);
+    ActionMenu* menu = context;
+    with_view_model(menu->view, ActionMenuModel * model, { model->scroll_counter = 0; }, true);
+    furi_timer_start(menu->scroll_timer, SCROLL_INTERVAL);
+}
+
+static void action_menu_view_exit_callback(void* context) {
+    furi_check(context);
+    ActionMenu* menu = context;
+    furi_timer_stop(menu->scroll_timer);
+}
+
 static void action_menu_draw_landscape(Canvas* canvas, ActionMenuModel* model) {
     const uint8_t item_height = 16;
     uint8_t item_width = canvas_width(canvas) - 5; // space for scrollbar
@@ -79,9 +116,10 @@ static void action_menu_draw_landscape(Canvas* canvas, ActionMenuModel* model) {
     for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
         ActionMenuItemArray_next(it)) {
         const size_t item_position = position - model->window_position;
+        bool selected = position == model->position;
 
         if(item_position < items_on_screen) {
-            if(position == model->position) {
+            if(selected) {
                 canvas_set_color(canvas, ColorBlack);
                 elements_slightly_rounded_box(
                     canvas,
@@ -103,16 +141,38 @@ static void action_menu_draw_landscape(Canvas* canvas, ActionMenuModel* model) {
                     ActionMenuIcons[item->type]);
             }
 
+            size_t scroll_counter = get_adjusted_scroll_counter(selected, model->scroll_counter);
+
             FuriString* disp_str;
             disp_str = furi_string_alloc_set(item->label);
-            elements_string_fit_width(canvas, disp_str, item_width - (6 * 2));
-
-            canvas_draw_str(
+            size_t width = item_width - x_txt_start - 2;
+            width -= item->is_link ? 2 : 0;
+            elements_scrollable_text_line(
                 canvas,
-                x_txt_start, // 6
+                x_txt_start,
                 y_offset + (item_position * item_height) + item_height - 4,
-                furi_string_get_cstr(disp_str));
+                width,
+                disp_str,
+                scroll_counter,
+                false);
             furi_string_free(disp_str);
+
+            // draw a link indicator/glyph/icon thing
+            if(item->is_link) {
+                // canvas_draw_line(
+                //     canvas,
+                //     item_width - 3,
+                //     y_offset + (item_position * item_height) + 3,
+                //     item_width - 3,
+                //     y_offset + (item_position * item_height) + item_height - 4);
+
+                canvas_draw_dot(
+                    canvas, item_width - 3, y_offset + (item_position * item_height) + 6);
+                canvas_draw_dot(
+                    canvas, item_width - 3, y_offset + (item_position * item_height) + 8);
+                canvas_draw_dot(
+                    canvas, item_width - 3, y_offset + (item_position * item_height) + 10);
+            }
         }
         position++;
     }
@@ -139,10 +199,16 @@ static void action_menu_draw_portrait(Canvas* canvas, ActionMenuModel* model) {
     }
 
     if(have_header) {
+        // Center the header if it fits, otherwise left-justify
         canvas_set_font(canvas, FontPrimary);
-        elements_string_fit_width(canvas, model->header, ITEM_WIDTH - 6);
-        canvas_draw_str_aligned(
-            canvas, 32, 10, AlignCenter, AlignCenter, furi_string_get_cstr(model->header));
+        uint16_t width = canvas_string_width(canvas, furi_string_get_cstr(model->header));
+        if(width <= ITEM_WIDTH) {
+            canvas_draw_str_aligned(
+                canvas, 32, 10, AlignCenter, AlignCenter, furi_string_get_cstr(model->header));
+        } else {
+            canvas_draw_str_aligned(
+                canvas, 0, 10, AlignLeft, AlignCenter, furi_string_get_cstr(model->header));
+        }
     }
     canvas_set_font(canvas, FontSecondary);
 
@@ -176,16 +242,37 @@ static void action_menu_draw_portrait(Canvas* canvas, ActionMenuModel* model) {
                 canvas_draw_icon(canvas, 3, item_y + 2, ActionMenuIcons[item->type]);
             }
 
+            size_t scroll_counter = get_adjusted_scroll_counter(selected, model->scroll_counter);
+
             FuriString* disp_str;
             disp_str = furi_string_alloc_set(item->label);
-            elements_string_fit_width(canvas, disp_str, ITEM_WIDTH - 6);
-
-            canvas_draw_str(
+            size_t width = ITEM_WIDTH - x_txt_start - 1;
+            width -= item->is_link ? 2 : 0;
+            elements_scrollable_text_line(
                 canvas,
                 x_txt_start,
                 item_y + (ITEM_HEIGHT / 2) + 3,
-                furi_string_get_cstr(disp_str));
+                width,
+                disp_str,
+                scroll_counter,
+                false);
             furi_string_free(disp_str);
+
+            // draw a link indicator/glyph/icon thing
+            if(item->is_link) {
+                // top-line, 2 rows
+                // canvas_draw_line(canvas, ITEM_WIDTH - 6, item_y, ITEM_WIDTH - 1, item_y);
+                // canvas_draw_line(canvas, ITEM_WIDTH - 6, item_y + 1, ITEM_WIDTH - 1, item_y + 1);
+
+                // single vertical line
+                // canvas_draw_line(
+                //     canvas, ITEM_WIDTH - 3, item_y + 2, ITEM_WIDTH - 3, item_y + ITEM_HEIGHT - 3);
+
+                // 3 vertical dots
+                canvas_draw_dot(canvas, ITEM_WIDTH - 3, item_y + 4);
+                canvas_draw_dot(canvas, ITEM_WIDTH - 3, item_y + 6);
+                canvas_draw_dot(canvas, ITEM_WIDTH - 3, item_y + 8);
+            }
         }
     }
 }
@@ -230,6 +317,7 @@ static void action_menu_process_up(ActionMenu* action_menu) {
                     }
                 }
             }
+            model->scroll_counter = 0;
         },
         true);
 }
@@ -261,6 +349,7 @@ static void action_menu_process_down(ActionMenu* action_menu) {
                     model->window_position = 0;
                 }
             }
+            model->scroll_counter = 0;
         },
         true);
 }
@@ -428,6 +517,11 @@ ActionMenu* action_menu_alloc(void) {
     view_allocate_model(action_menu->view, ViewModelTypeLocking, sizeof(ActionMenuModel));
     view_set_draw_callback(action_menu->view, action_menu_view_draw_callback);
     view_set_input_callback(action_menu->view, action_menu_view_input_callback);
+    view_set_enter_callback(action_menu->view, action_menu_view_enter_callback);
+    view_set_exit_callback(action_menu->view, action_menu_view_exit_callback);
+
+    action_menu->scroll_timer =
+        furi_timer_alloc(action_menu_scroll_timer_callback, FuriTimerTypePeriodic, action_menu);
 
     with_view_model(
         action_menu->view,
@@ -463,6 +557,7 @@ void action_menu_free(ActionMenu* action_menu) {
         },
         true);
     view_free(action_menu->view);
+    furi_timer_free(action_menu->scroll_timer);
     free(action_menu);
 }
 
@@ -526,4 +621,9 @@ void action_menu_set_selected_item(ActionMenu* action_menu, uint32_t index) {
             },
             true);
     }
+}
+
+void action_menu_item_set_link(ActionMenuItem* action_item, bool is_link) {
+    furi_assert(action_item);
+    action_item->is_link = is_link;
 }

+ 7 - 0
quac/views/action_menu.h

@@ -116,6 +116,13 @@ void action_menu_set_header(ActionMenu* action_menu, const char* header);
  */
 void action_menu_set_selected_item(ActionMenu* action_menu, uint32_t index);
 
+/** Set whether this item is a link
+ *
+ * @param      action_item  ActionMenuItem pointer
+ * @param      is_link      bool
+ */
+void action_menu_item_set_link(ActionMenuItem* action_item, bool is_link);
+
 #ifdef __cplusplus
 }
 #endif