diff --git a/README.md b/README.md index d5cabb6..ccf00a9 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,7 @@ opération, la touche `C` demande son annulation. L'écran Import présente un catalogue en mémoire vérifié par rapport au manifeste et aux fichiers de `images/originals`. Les flèches, `j`/`k`, PageUp, PageDown, Home et End naviguent dans la liste ; `R` recharge le catalogue. +La touche `S` fait défiler les tris par ordre d'import, nom ou taille, dans les +deux directions. `/` filtre les noms d'images sans distinction de casse ASCII et +`X` efface le filtre. Le tri et le filtre restent entièrement en mémoire et ne +modifient ni le manifeste ni les images. diff --git a/include/lardon3d/app_state.h b/include/lardon3d/app_state.h index fef79df..e59d316 100644 --- a/include/lardon3d/app_state.h +++ b/include/lardon3d/app_state.h @@ -3,9 +3,8 @@ #include #include -#include - typedef struct Lardon3DImageCatalog Lardon3DImageCatalog; +typedef struct Lardon3DImageView Lardon3DImageView; typedef enum { LARDON3D_SCREEN_HOME = 0, @@ -23,8 +22,7 @@ typedef struct { char project_path[PATH_MAX]; char status_message[256]; Lardon3DImageCatalog *image_catalog; - size_t image_selection; - size_t image_offset; + Lardon3DImageView *image_view; } Lardon3DAppState; void lardon3d_app_state_init(Lardon3DAppState *state); diff --git a/include/lardon3d/image_view.h b/include/lardon3d/image_view.h new file mode 100644 index 0000000..8340efb --- /dev/null +++ b/include/lardon3d/image_view.h @@ -0,0 +1,71 @@ +#ifndef LARDON3D_IMAGE_VIEW_H +#define LARDON3D_IMAGE_VIEW_H + +#include +#include +#include + +#include + +enum { + LARDON3D_IMAGE_FILTER_CAPACITY = 128, +}; + +typedef enum { + LARDON3D_IMAGE_SORT_IMPORT_ORDER = 0, + LARDON3D_IMAGE_SORT_NAME_ASC, + LARDON3D_IMAGE_SORT_NAME_DESC, + LARDON3D_IMAGE_SORT_SIZE_ASC, + LARDON3D_IMAGE_SORT_SIZE_DESC +} Lardon3DImageSort; + +Lardon3DImageView *lardon3d_image_view_create( + const Lardon3DImageCatalog *catalog +); +void lardon3d_image_view_destroy(Lardon3DImageView *view); +bool lardon3d_image_view_set_catalog( + Lardon3DImageView *view, + const Lardon3DImageCatalog *catalog +); +bool lardon3d_image_view_set_sort( + Lardon3DImageView *view, + Lardon3DImageSort sort +); +bool lardon3d_image_view_set_filter( + Lardon3DImageView *view, + const char *filter, + char *error_message, + size_t error_message_size +); +bool lardon3d_image_view_rebuild(Lardon3DImageView *view); +size_t lardon3d_image_view_count(const Lardon3DImageView *view); +uint64_t lardon3d_image_view_total_size(const Lardon3DImageView *view); +const Lardon3DImageEntry *lardon3d_image_view_get( + const Lardon3DImageView *view, + size_t visible_index +); +bool lardon3d_image_view_catalog_index( + const Lardon3DImageView *view, + size_t visible_index, + size_t *catalog_index +); +Lardon3DImageSort lardon3d_image_view_sort(const Lardon3DImageView *view); +const char *lardon3d_image_view_filter(const Lardon3DImageView *view); +const char *lardon3d_image_view_sort_name(Lardon3DImageSort sort); +size_t lardon3d_image_view_selection(const Lardon3DImageView *view); +size_t lardon3d_image_view_offset(const Lardon3DImageView *view); +void lardon3d_image_view_select( + Lardon3DImageView *view, + size_t visible_index, + size_t page_size +); +void lardon3d_image_view_normalize( + Lardon3DImageView *view, + size_t page_size +); +bool lardon3d_image_view_ascii_contains( + const char *text, + const char *filter +); + +#endif diff --git a/meson.build b/meson.build index e8ee844..45e9937 100644 --- a/meson.build +++ b/meson.build @@ -32,6 +32,7 @@ executable( 'src/import.c', 'src/import_task.c', 'src/image_catalog.c', + 'src/image_view.c', 'src/project.c', ], include_directories: include_directories('include'), @@ -75,3 +76,16 @@ image_catalog_test = executable( ) test('image-catalog', image_catalog_test, timeout: 30) + +image_view_test = executable( + 'test-image-view', + sources: [ + 'tests/test_image_view.c', + 'src/app_state.c', + 'src/image_catalog.c', + 'src/image_view.c', + ], + include_directories: include_directories('include'), +) + +test('image-view', image_view_test, timeout: 30) diff --git a/src/app.c b/src/app.c index 889b54e..7f440be 100644 --- a/src/app.c +++ b/src/app.c @@ -4,6 +4,7 @@ #include #include #include +#include #include int @@ -21,6 +22,7 @@ lardon3d_app_run(void) } bool success = lardon3d_tui_run(&state); + lardon3d_image_view_destroy(state.image_view); lardon3d_image_catalog_destroy(state.image_catalog); lardon3d_tui_shutdown(); diff --git a/src/image_view.c b/src/image_view.c new file mode 100644 index 0000000..9ae73e7 --- /dev/null +++ b/src/image_view.c @@ -0,0 +1,436 @@ +#include +#include +#include +#include +#include + +#include + +struct Lardon3DImageView { + const Lardon3DImageCatalog *catalog; + size_t *indices; + size_t count; + uint64_t total_size; + Lardon3DImageSort sort; + char filter[LARDON3D_IMAGE_FILTER_CAPACITY]; + size_t selection; + size_t offset; +}; + +static unsigned char +ascii_lower(unsigned char character) +{ + return character >= 'A' && character <= 'Z' + ? (unsigned char)(character + ('a' - 'A')) + : character; +} + +bool +lardon3d_image_view_ascii_contains( + const char *text, + const char *filter +) +{ + if (!text || !filter) { + return false; + } + if (!filter[0]) { + return true; + } + for (const unsigned char *start = (const unsigned char *)text; + *start; + ++start) { + const unsigned char *left = start; + const unsigned char *right = (const unsigned char *)filter; + while (*left && *right && ascii_lower(*left) == ascii_lower(*right)) { + ++left; + ++right; + } + if (!*right) { + return true; + } + } + return false; +} + +static int +compare_indices( + const Lardon3DImageView *view, + size_t left_index, + size_t right_index +) +{ + const Lardon3DImageEntry *left = lardon3d_image_catalog_get( + view->catalog, + left_index + ); + const Lardon3DImageEntry *right = lardon3d_image_catalog_get( + view->catalog, + right_index + ); + if (!left || !right) { + return left ? -1 : right ? 1 : 0; + } + + int name_comparison = strcmp(left->filename, right->filename); + switch (view->sort) { + case LARDON3D_IMAGE_SORT_NAME_ASC: + return name_comparison; + case LARDON3D_IMAGE_SORT_NAME_DESC: + return name_comparison > 0 ? -1 : name_comparison < 0 ? 1 : 0; + case LARDON3D_IMAGE_SORT_SIZE_ASC: + case LARDON3D_IMAGE_SORT_SIZE_DESC: + if (left->size_bytes != right->size_bytes) { + bool left_first = view->sort == LARDON3D_IMAGE_SORT_SIZE_ASC + ? left->size_bytes < right->size_bytes + : left->size_bytes > right->size_bytes; + return left_first ? -1 : 1; + } + return name_comparison; + case LARDON3D_IMAGE_SORT_IMPORT_ORDER: + default: + return left_index < right_index ? -1 : left_index > right_index ? 1 : 0; + } +} + +static void +merge_sort_range( + Lardon3DImageView *view, + size_t *indices, + size_t *temporary, + size_t begin, + size_t end +) +{ + if (end - begin < 2) { + return; + } + size_t middle = begin + (end - begin) / 2; + merge_sort_range(view, indices, temporary, begin, middle); + merge_sort_range(view, indices, temporary, middle, end); + + size_t left = begin; + size_t right = middle; + size_t output = begin; + while (left < middle || right < end) { + if (right >= end + || (left < middle + && compare_indices(view, indices[left], indices[right]) <= 0)) { + temporary[output++] = indices[left++]; + } else { + temporary[output++] = indices[right++]; + } + } + (void)memcpy( + indices + begin, + temporary + begin, + (end - begin) * sizeof(*indices) + ); +} + +static bool +selected_catalog_index( + const Lardon3DImageView *view, + size_t *catalog_index +) +{ + if (!view || view->selection >= view->count) { + return false; + } + *catalog_index = view->indices[view->selection]; + return true; +} + +static bool +build_view( + Lardon3DImageView *view, + const Lardon3DImageCatalog *catalog, + Lardon3DImageSort sort, + const char *filter +) +{ + char filter_copy[LARDON3D_IMAGE_FILTER_CAPACITY]; + (void)snprintf(filter_copy, sizeof(filter_copy), "%s", filter); + size_t preserved_index = 0; + bool preserve = selected_catalog_index(view, &preserved_index); + size_t catalog_count = lardon3d_image_catalog_count(catalog); + if (catalog_count > SIZE_MAX / sizeof(*view->indices)) { + return false; + } + size_t *indices = catalog_count > 0 + ? malloc(catalog_count * sizeof(*indices)) + : NULL; + if (catalog_count > 0 && !indices) { + return false; + } + + size_t count = 0; + uint64_t total_size = 0; + for (size_t index = 0; index < catalog_count; ++index) { + const Lardon3DImageEntry *entry = lardon3d_image_catalog_get( + catalog, + index + ); + if (entry + && lardon3d_image_view_ascii_contains( + entry->filename, + filter_copy + )) { + indices[count++] = index; + total_size += entry->size_bytes; + } + } + + const Lardon3DImageCatalog *old_catalog = view->catalog; + Lardon3DImageSort old_sort = view->sort; + view->catalog = catalog; + view->sort = sort; + if (sort != LARDON3D_IMAGE_SORT_IMPORT_ORDER && count > 1) { + size_t *temporary = malloc(count * sizeof(*temporary)); + if (!temporary) { + view->catalog = old_catalog; + view->sort = old_sort; + free(indices); + return false; + } + merge_sort_range(view, indices, temporary, 0, count); + free(temporary); + } + + free(view->indices); + view->indices = indices; + view->count = count; + view->total_size = total_size; + view->selection = 0; + view->offset = 0; + (void)snprintf(view->filter, sizeof(view->filter), "%s", filter_copy); + if (preserve) { + for (size_t index = 0; index < count; ++index) { + if (indices[index] == preserved_index) { + view->selection = index; + break; + } + } + } + return true; +} + +Lardon3DImageView * +lardon3d_image_view_create(const Lardon3DImageCatalog *catalog) +{ + Lardon3DImageView *view = calloc(1, sizeof(*view)); + if (!view) { + return NULL; + } + view->catalog = catalog; + view->sort = LARDON3D_IMAGE_SORT_IMPORT_ORDER; + if (!build_view(view, catalog, view->sort, "")) { + free(view); + return NULL; + } + return view; +} + +void +lardon3d_image_view_destroy(Lardon3DImageView *view) +{ + if (!view) { + return; + } + free(view->indices); + free(view); +} + +bool +lardon3d_image_view_set_catalog( + Lardon3DImageView *view, + const Lardon3DImageCatalog *catalog +) +{ + return view && build_view(view, catalog, view->sort, view->filter); +} + +bool +lardon3d_image_view_set_sort( + Lardon3DImageView *view, + Lardon3DImageSort sort +) +{ + if (!view || sort < LARDON3D_IMAGE_SORT_IMPORT_ORDER + || sort > LARDON3D_IMAGE_SORT_SIZE_DESC) { + return false; + } + return build_view(view, view->catalog, sort, view->filter); +} + +bool +lardon3d_image_view_set_filter( + Lardon3DImageView *view, + const char *filter, + char *error_message, + size_t error_message_size +) +{ + if (error_message && error_message_size > 0) { + error_message[0] = '\0'; + } + if (!view || !filter) { + if (error_message && error_message_size > 0) { + (void)snprintf( + error_message, + error_message_size, + "Erreur : filtre invalide." + ); + } + return false; + } + if (strnlen(filter, LARDON3D_IMAGE_FILTER_CAPACITY) + >= LARDON3D_IMAGE_FILTER_CAPACITY) { + if (error_message && error_message_size > 0) { + (void)snprintf( + error_message, + error_message_size, + "Erreur : filtre trop long." + ); + } + return false; + } + if (!build_view(view, view->catalog, view->sort, filter)) { + if (error_message && error_message_size > 0) { + (void)snprintf( + error_message, + error_message_size, + "Erreur : mémoire insuffisante pour la vue." + ); + } + return false; + } + return true; +} + +bool +lardon3d_image_view_rebuild(Lardon3DImageView *view) +{ + return view && build_view(view, view->catalog, view->sort, view->filter); +} + +size_t +lardon3d_image_view_count(const Lardon3DImageView *view) +{ + return view ? view->count : 0; +} + +uint64_t +lardon3d_image_view_total_size(const Lardon3DImageView *view) +{ + return view ? view->total_size : 0; +} + +const Lardon3DImageEntry * +lardon3d_image_view_get( + const Lardon3DImageView *view, + size_t visible_index +) +{ + return view && visible_index < view->count + ? lardon3d_image_catalog_get(view->catalog, view->indices[visible_index]) + : NULL; +} + +bool +lardon3d_image_view_catalog_index( + const Lardon3DImageView *view, + size_t visible_index, + size_t *catalog_index +) +{ + if (!view || !catalog_index || visible_index >= view->count) { + return false; + } + *catalog_index = view->indices[visible_index]; + return true; +} + +Lardon3DImageSort +lardon3d_image_view_sort(const Lardon3DImageView *view) +{ + return view ? view->sort : LARDON3D_IMAGE_SORT_IMPORT_ORDER; +} + +const char * +lardon3d_image_view_filter(const Lardon3DImageView *view) +{ + return view ? view->filter : ""; +} + +const char * +lardon3d_image_view_sort_name(Lardon3DImageSort sort) +{ + switch (sort) { + case LARDON3D_IMAGE_SORT_NAME_ASC: + return "Nom croissant"; + case LARDON3D_IMAGE_SORT_NAME_DESC: + return "Nom décroissant"; + case LARDON3D_IMAGE_SORT_SIZE_ASC: + return "Taille croissante"; + case LARDON3D_IMAGE_SORT_SIZE_DESC: + return "Taille décroissante"; + case LARDON3D_IMAGE_SORT_IMPORT_ORDER: + default: + return "Ordre d'import"; + } +} + +size_t +lardon3d_image_view_selection(const Lardon3DImageView *view) +{ + return view ? view->selection : 0; +} + +size_t +lardon3d_image_view_offset(const Lardon3DImageView *view) +{ + return view ? view->offset : 0; +} + +void +lardon3d_image_view_normalize( + Lardon3DImageView *view, + size_t page_size +) +{ + if (!view) { + return; + } + if (view->count == 0) { + view->selection = 0; + view->offset = 0; + return; + } + if (view->selection >= view->count) { + view->selection = view->count - 1; + } + if (view->offset > view->selection) { + view->offset = view->selection; + } + if (page_size == 0) { + page_size = 1; + } + if (view->selection - view->offset >= page_size) { + view->offset = view->selection - page_size + 1; + } +} + +void +lardon3d_image_view_select( + Lardon3DImageView *view, + size_t visible_index, + size_t page_size +) +{ + if (!view) { + return; + } + view->selection = visible_index; + lardon3d_image_view_normalize(view, page_size); +} diff --git a/src/layout.c b/src/layout.c index 124923c..a3d05ba 100644 --- a/src/layout.c +++ b/src/layout.c @@ -4,6 +4,7 @@ #include #include +#include enum { MINIMUM_ROWS = 20, @@ -71,7 +72,7 @@ screen_texts( case LARDON3D_SCREEN_IMPORT: *title = "Import"; *content = "Import des images"; - *footer = "I Importer R Recharger ↑/↓ Naviguer ESC Accueil Q Quit"; + *footer = "I Importer R Recharger S Tri / Filtre X Effacer ↑/↓ ESC Q"; break; case LARDON3D_SCREEN_VIEWER: *title = "Viewer"; @@ -143,34 +144,67 @@ draw_catalog( draw_text(7, 4, columns - 6, "Aucun projet chargé."); return; } - size_t count = lardon3d_image_catalog_count(state->image_catalog); + size_t count = lardon3d_image_view_count(state->image_view); + size_t total = lardon3d_image_catalog_count(state->image_catalog); char line[512]; (void)snprintf( line, sizeof(line), - "Images importées : %zu", - count + "Images visibles : %zu / %zu", + count, + total ); draw_text(6, 4, columns - 6, line); char size_text[64]; lardon3d_image_catalog_format_size( - lardon3d_image_catalog_total_size(state->image_catalog), + lardon3d_image_view_total_size(state->image_view), size_text, sizeof(size_text) ); - (void)snprintf(line, sizeof(line), "Taille totale : %s", size_text); + (void)snprintf( + line, + sizeof(line), + "Taille totale visible : %s", + size_text + ); draw_text(7, 4, columns - 6, line); + (void)snprintf( + line, + sizeof(line), + "Tri : %s", + lardon3d_image_view_sort_name( + lardon3d_image_view_sort(state->image_view) + ) + ); + draw_text(8, 4, columns - 6, line); + const char *filter = lardon3d_image_view_filter(state->image_view); + (void)snprintf( + line, + sizeof(line), + "Filtre : %s", + filter[0] ? filter : "Aucun" + ); + draw_text(9, 4, columns - 6, line); if (count == 0) { - draw_text(9, 4, columns - 6, "Aucune image importée."); + draw_text( + 11, + 4, + columns - 6, + filter[0] + ? "Aucune image ne correspond au filtre." + : "Aucune image importée." + ); return; } int journal_row = rows - 7; - size_t visible = journal_row > 9 ? (size_t)(journal_row - 9) : 0; + size_t visible = journal_row > 11 ? (size_t)(journal_row - 11) : 0; + size_t offset = lardon3d_image_view_offset(state->image_view); + size_t selection = lardon3d_image_view_selection(state->image_view); for (size_t row = 0; row < visible; ++row) { - size_t index = state->image_offset + row; - const Lardon3DImageEntry *entry = lardon3d_image_catalog_get( - state->image_catalog, + size_t index = offset + row; + const Lardon3DImageEntry *entry = lardon3d_image_view_get( + state->image_view, index ); if (!entry) { @@ -185,11 +219,11 @@ draw_catalog( line, sizeof(line), "%c %s %s", - index == state->image_selection ? '>' : ' ', + index == selection ? '>' : ' ', entry->filename, size_text ); - draw_text(9 + (int)row, 4, columns - 6, line); + draw_text(11 + (int)row, 4, columns - 6, line); } } diff --git a/src/project.c b/src/project.c index 83d1fcc..1e96e82 100644 --- a/src/project.c +++ b/src/project.c @@ -11,6 +11,7 @@ #include #include +#include enum { MAX_CREATED_DIRECTORIES = 16, @@ -36,10 +37,10 @@ set_status(Lardon3DAppState *state, const char *message) static void clear_catalog(Lardon3DAppState *state) { + lardon3d_image_view_destroy(state->image_view); + state->image_view = NULL; lardon3d_image_catalog_destroy(state->image_catalog); state->image_catalog = NULL; - state->image_selection = 0; - state->image_offset = 0; } static bool diff --git a/src/tui.c b/src/tui.c index cd9fa1a..5988402 100644 --- a/src/tui.c +++ b/src/tui.c @@ -3,9 +3,11 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -20,6 +22,7 @@ typedef enum { INPUT_PROJECT_CREATE, INPUT_PROJECT_OPEN, INPUT_IMPORT_DIRECTORY, + INPUT_IMAGE_FILTER, } InputMode; typedef struct { @@ -35,58 +38,108 @@ catalog_page_size(void) int columns; getmaxyx(stdscr, rows, columns); (void)columns; - return rows > 16 ? (size_t)(rows - 16) : 1; + return rows > 18 ? (size_t)(rows - 18) : 1; } static void -normalize_catalog_navigation(Lardon3DAppState *state) +normalize_view_navigation(Lardon3DAppState *state) { - size_t count = lardon3d_image_catalog_count(state->image_catalog); - if (count == 0) { - state->image_selection = 0; - state->image_offset = 0; - return; - } - if (state->image_selection >= count) { - state->image_selection = count - 1; - } - if (state->image_offset > state->image_selection) { - state->image_offset = state->image_selection; - } - size_t page_size = catalog_page_size(); - if (state->image_selection - state->image_offset >= page_size) { - state->image_offset = state->image_selection - page_size + 1; - } + lardon3d_image_view_normalize(state->image_view, catalog_page_size()); } static bool reload_catalog(Lardon3DAppState *state, bool announce_success) { + Lardon3DImageSort sort = lardon3d_image_view_sort(state->image_view); + char filter[LARDON3D_IMAGE_FILTER_CAPACITY]; + (void)snprintf( + filter, + sizeof(filter), + "%s", + lardon3d_image_view_filter(state->image_view) + ); + size_t selected_catalog = 0; + bool preserve_selection = lardon3d_image_view_catalog_index( + state->image_view, + lardon3d_image_view_selection(state->image_view), + &selected_catalog + ); + lardon3d_image_view_destroy(state->image_view); + state->image_view = NULL; lardon3d_image_catalog_destroy(state->image_catalog); state->image_catalog = NULL; - char message[sizeof(state->status_message)]; + char message[sizeof(state->status_message)] = ""; state->image_catalog = lardon3d_image_catalog_load( state, message, sizeof(message) ); - normalize_catalog_navigation(state); - if (!state->image_catalog || message[0]) { + if (!state->image_catalog) { (void)snprintf( state->status_message, sizeof(state->status_message), "%s", message ); - return state->image_catalog != NULL; + return false; + } + char catalog_message[sizeof(message)]; + (void)snprintf(catalog_message, sizeof(catalog_message), "%s", message); + char view_error[sizeof(message)]; + state->image_view = lardon3d_image_view_create(state->image_catalog); + if (!state->image_view + || !lardon3d_image_view_set_sort(state->image_view, sort) + || !lardon3d_image_view_set_filter( + state->image_view, + filter, + view_error, + sizeof(view_error) + )) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Erreur : impossible de construire la vue des images." + ); + lardon3d_image_view_destroy(state->image_view); + state->image_view = NULL; + return false; + } + if (preserve_selection) { + size_t count = lardon3d_image_view_count(state->image_view); + for (size_t index = 0; index < count; ++index) { + size_t catalog_index; + if (lardon3d_image_view_catalog_index( + state->image_view, + index, + &catalog_index + ) + && catalog_index == selected_catalog) { + lardon3d_image_view_select( + state->image_view, + index, + catalog_page_size() + ); + break; + } + } + } + normalize_view_navigation(state); + if (catalog_message[0]) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "%s", + catalog_message + ); + return true; } if (announce_success) { (void)snprintf( state->status_message, sizeof(state->status_message), "Catalogue rechargé : %zu image%s.", - lardon3d_image_catalog_count(state->image_catalog), - lardon3d_image_catalog_count(state->image_catalog) == 1 ? "" : "s" + lardon3d_image_view_count(state->image_view), + lardon3d_image_view_count(state->image_view) == 1 ? "" : "s" ); } return true; @@ -109,6 +162,8 @@ redraw( label = "Nom du dossier projet :"; } else if (input->mode == INPUT_IMPORT_DIRECTORY) { label = "Dossier source :"; + } else if (input->mode == INPUT_IMAGE_FILTER) { + label = "Filtre :"; } Lardon3DImportTaskSnapshot snapshot; const Lardon3DImportTaskSnapshot *displayed_snapshot = NULL; @@ -187,6 +242,8 @@ handle_active_input( message = "Ouverture du projet annulée."; } else if (input->mode == INPUT_IMPORT_DIRECTORY) { message = "Import annulé."; + } else if (input->mode == INPUT_IMAGE_FILTER) { + message = "Filtre annulé."; } input->mode = INPUT_NONE; (void)snprintf( @@ -205,6 +262,37 @@ handle_active_input( } } else if (input->mode == INPUT_IMPORT_DIRECTORY) { (void)start_import_task(state, input, task); + } else if (input->mode == INPUT_IMAGE_FILTER) { + char message[sizeof(state->status_message)]; + if (lardon3d_image_view_set_filter( + state->image_view, + input->text, + message, + sizeof(message) + )) { + normalize_view_navigation(state); + if (lardon3d_image_view_count(state->image_view) == 0) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Aucune image ne correspond au filtre." + ); + } else { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Filtre appliqué : %.230s", + input->text + ); + } + } else { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "%s", + message + ); + } } else { if (lardon3d_project_create(state, input->text)) { (void)reload_catalog(state, false); @@ -223,7 +311,18 @@ handle_active_input( } if (key >= 0 && key <= UCHAR_MAX && isprint((unsigned char)key)) { - if (input->length + 1 >= sizeof(input->text)) { + size_t capacity = input->mode == INPUT_IMAGE_FILTER + ? LARDON3D_IMAGE_FILTER_CAPACITY + : sizeof(input->text); + if (input->length + 1 >= capacity) { + if (input->mode == INPUT_IMAGE_FILTER) { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Erreur : filtre trop long." + ); + return true; + } if (input->mode == INPUT_PROJECT_OPEN) { if (lardon3d_project_open(state, input->text)) { (void)reload_catalog(state, false); @@ -301,24 +400,31 @@ handle_normal_input( state->screen = LARDON3D_SCREEN_HOME; return true; case KEY_RESIZE: - normalize_catalog_navigation(state); + normalize_view_navigation(state); return true; case KEY_UP: case 'k': if (state->screen == LARDON3D_SCREEN_IMPORT - && state->image_selection > 0) { - --state->image_selection; - normalize_catalog_navigation(state); + && lardon3d_image_view_selection(state->image_view) > 0) { + lardon3d_image_view_select( + state->image_view, + lardon3d_image_view_selection(state->image_view) - 1, + catalog_page_size() + ); return true; } return false; case KEY_DOWN: case 'j': { - size_t count = lardon3d_image_catalog_count(state->image_catalog); + size_t count = lardon3d_image_view_count(state->image_view); + size_t selection = lardon3d_image_view_selection(state->image_view); if (state->screen == LARDON3D_SCREEN_IMPORT - && state->image_selection + 1 < count) { - ++state->image_selection; - normalize_catalog_navigation(state); + && selection < count && selection + 1 < count) { + lardon3d_image_view_select( + state->image_view, + selection + 1, + catalog_page_size() + ); return true; } return false; @@ -326,39 +432,53 @@ handle_normal_input( case KEY_PPAGE: if (state->screen == LARDON3D_SCREEN_IMPORT) { size_t page_size = catalog_page_size(); - state->image_selection = state->image_selection > page_size - ? state->image_selection - page_size + size_t selection = lardon3d_image_view_selection(state->image_view); + selection = selection > page_size + ? selection - page_size : 0; - normalize_catalog_navigation(state); + lardon3d_image_view_select(state->image_view, selection, page_size); return true; } return false; case KEY_NPAGE: if (state->screen == LARDON3D_SCREEN_IMPORT) { - size_t count = lardon3d_image_catalog_count(state->image_catalog); + size_t count = lardon3d_image_view_count(state->image_view); if (count > 0) { - size_t remaining = count - 1 - state->image_selection; + size_t selection = lardon3d_image_view_selection( + state->image_view + ); + size_t remaining = count - 1 - selection; size_t page_size = catalog_page_size(); - state->image_selection += remaining < page_size + selection += remaining < page_size ? remaining : page_size; - normalize_catalog_navigation(state); + lardon3d_image_view_select( + state->image_view, + selection, + page_size + ); } return true; } return false; case KEY_HOME: if (state->screen == LARDON3D_SCREEN_IMPORT) { - state->image_selection = 0; - normalize_catalog_navigation(state); + lardon3d_image_view_select( + state->image_view, + 0, + catalog_page_size() + ); return true; } return false; case KEY_END: if (state->screen == LARDON3D_SCREEN_IMPORT) { - size_t count = lardon3d_image_catalog_count(state->image_catalog); - state->image_selection = count > 0 ? count - 1 : 0; - normalize_catalog_navigation(state); + size_t count = lardon3d_image_view_count(state->image_view); + lardon3d_image_view_select( + state->image_view, + count > 0 ? count - 1 : 0, + catalog_page_size() + ); return true; } return false; @@ -418,6 +538,59 @@ handle_normal_input( return true; } return false; + case 's': + case 'S': + if (state->screen == LARDON3D_SCREEN_IMPORT && state->image_view) { + Lardon3DImageSort sort = lardon3d_image_view_sort( + state->image_view + ); + sort = (Lardon3DImageSort)( + ((int)sort + 1) % ((int)LARDON3D_IMAGE_SORT_SIZE_DESC + 1) + ); + if (lardon3d_image_view_set_sort(state->image_view, sort)) { + normalize_view_navigation(state); + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Tri : %s.", + lardon3d_image_view_sort_name(sort) + ); + } + return true; + } + return false; + case '/': + if (state->screen == LARDON3D_SCREEN_IMPORT && state->image_view) { + const char *filter = lardon3d_image_view_filter(state->image_view); + *input = (TuiInput) { + .mode = INPUT_IMAGE_FILTER, + .text = "", + .length = strlen(filter), + }; + (void)snprintf(input->text, sizeof(input->text), "%s", filter); + return true; + } + return false; + case 'x': + case 'X': + if (state->screen == LARDON3D_SCREEN_IMPORT && state->image_view) { + char message[sizeof(state->status_message)]; + if (lardon3d_image_view_set_filter( + state->image_view, + "", + message, + sizeof(message) + )) { + normalize_view_navigation(state); + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Filtre effacé." + ); + } + return true; + } + return false; case 'c': case 'C': if (state->screen == LARDON3D_SCREEN_PROJECTS) { diff --git a/tests/test_image_view.c b/tests/test_image_view.c new file mode 100644 index 0000000..ce1b9d0 --- /dev/null +++ b/tests/test_image_view.c @@ -0,0 +1,316 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "Échec ligne %d : %s\n", __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +static bool +join_path(char destination[PATH_MAX], const char *parent, const char *child) +{ + int written = snprintf(destination, PATH_MAX, "%s/%s", parent, child); + return written >= 0 && (size_t)written < PATH_MAX; +} + +static bool +create_file(const char *path, size_t size) +{ + int descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (descriptor < 0) { + return false; + } + char bytes[16] = {0}; + size_t remaining = size; + while (remaining > 0) { + size_t length = remaining < sizeof(bytes) ? remaining : sizeof(bytes); + ssize_t written = write(descriptor, bytes, length); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + (void)close(descriptor); + return false; + } + remaining -= (size_t)written; + } + return close(descriptor) == 0; +} + +static bool +write_manifest(const char *path, const char *content) +{ + int descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (descriptor < 0) { + return false; + } + size_t length = strlen(content); + size_t total = 0; + while (total < length) { + ssize_t written = write(descriptor, content + total, length - total); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + (void)close(descriptor); + return false; + } + total += (size_t)written; + } + return close(descriptor) == 0; +} + +static bool +remove_tree(const char *path) +{ + struct stat info; + if (lstat(path, &info) != 0) { + return errno == ENOENT; + } + if (!S_ISDIR(info.st_mode)) { + return unlink(path) == 0; + } + DIR *directory = opendir(path); + if (!directory) { + return false; + } + bool success = true; + for (struct dirent *entry = readdir(directory); + entry; + entry = readdir(directory)) { + if (strcmp(entry->d_name, ".") == 0 + || strcmp(entry->d_name, "..") == 0) { + continue; + } + char child[PATH_MAX]; + if (!join_path(child, path, entry->d_name) || !remove_tree(child)) { + success = false; + } + } + if (closedir(directory) != 0 || rmdir(path) != 0) { + success = false; + } + return success; +} + +static Lardon3DImageCatalog * +load_catalog(const char *project) +{ + Lardon3DAppState state; + lardon3d_app_state_init(&state); + state.project_loaded = true; + (void)snprintf(state.project_path, sizeof(state.project_path), "%s", project); + char error[256]; + return lardon3d_image_catalog_load(&state, error, sizeof(error)); +} + +static bool +expect_names( + const Lardon3DImageView *view, + const char *const *names, + size_t count +) +{ + CHECK(lardon3d_image_view_count(view) == count); + for (size_t index = 0; index < count; ++index) { + const Lardon3DImageEntry *entry = lardon3d_image_view_get(view, index); + CHECK(entry && strcmp(entry->filename, names[index]) == 0); + } + CHECK(lardon3d_image_view_get(view, count) == NULL); + return true; +} + +static bool +run_test(void) +{ + char base[] = "/tmp/lardon3d-image-view.XXXXXX"; + CHECK(mkdtemp(base)); + char project[PATH_MAX]; + char images[PATH_MAX]; + char originals[PATH_MAX]; + char manifest[PATH_MAX]; + CHECK(join_path(project, base, "project")); + CHECK(join_path(images, project, "images")); + CHECK(join_path(originals, images, "originals")); + CHECK(join_path(manifest, images, "manifest.tsv")); + CHECK(mkdir(project, 0755) == 0); + CHECK(mkdir(images, 0755) == 0); + CHECK(mkdir(originals, 0755) == 0); + + Lardon3DImageCatalog *empty_catalog = load_catalog(project); + CHECK(empty_catalog); + Lardon3DImageView *view = lardon3d_image_view_create(empty_catalog); + CHECK(view); + CHECK(lardon3d_image_view_count(view) == 0); + CHECK(lardon3d_image_view_total_size(view) == 0); + lardon3d_image_view_normalize(view, 0); + CHECK(lardon3d_image_view_selection(view) == 0); + CHECK(lardon3d_image_view_offset(view) == 0); + lardon3d_image_view_destroy(view); + lardon3d_image_catalog_destroy(empty_catalog); + lardon3d_image_view_destroy(NULL); + + static const struct { + const char *name; + size_t size; + } files[] = { + {"Zulu.JPG", 5}, + {"alpha.png", 2}, + {"Beta.jpg", 5}, + {"été.png", 3}, + {"motor final.TIF", 8}, + }; + for (size_t index = 0; index < sizeof(files) / sizeof(files[0]); ++index) { + char path[PATH_MAX]; + CHECK(join_path(path, originals, files[index].name)); + CHECK(create_file(path, files[index].size)); + } + CHECK(write_manifest( + manifest, + "filename\tsize_bytes\tsource_path\n" + "Zulu.JPG\t5\t/tmp/source/Zulu.JPG\n" + "alpha.png\t2\t/tmp/source/alpha.png\n" + "Beta.jpg\t5\t/tmp/source/Beta.jpg\n" + "été.png\t3\t/tmp/source avec espaces/été.png\n" + "motor final.TIF\t8\t/tmp/source/motor final.TIF\n" + )); + Lardon3DImageCatalog *catalog = load_catalog(project); + CHECK(catalog && lardon3d_image_catalog_count(catalog) == 5); + const Lardon3DImageEntry *original_entries[5]; + for (size_t index = 0; index < 5; ++index) { + original_entries[index] = lardon3d_image_catalog_get(catalog, index); + } + view = lardon3d_image_view_create(catalog); + CHECK(view); + + static const char *const import_order[] = { + "Zulu.JPG", "alpha.png", "Beta.jpg", "été.png", "motor final.TIF", + }; + static const char *const name_ascending[] = { + "Beta.jpg", "Zulu.JPG", "alpha.png", "motor final.TIF", "été.png", + }; + static const char *const name_descending[] = { + "été.png", "motor final.TIF", "alpha.png", "Zulu.JPG", "Beta.jpg", + }; + static const char *const size_ascending[] = { + "alpha.png", "été.png", "Beta.jpg", "Zulu.JPG", "motor final.TIF", + }; + static const char *const size_descending[] = { + "motor final.TIF", "Beta.jpg", "Zulu.JPG", "été.png", "alpha.png", + }; + CHECK(expect_names(view, import_order, 5)); + CHECK(lardon3d_image_view_total_size(view) == 23); + CHECK(lardon3d_image_view_rebuild(view)); + CHECK(expect_names(view, import_order, 5)); + CHECK(lardon3d_image_view_set_sort(view, LARDON3D_IMAGE_SORT_NAME_ASC)); + CHECK(expect_names(view, name_ascending, 5)); + CHECK(lardon3d_image_view_set_sort(view, LARDON3D_IMAGE_SORT_NAME_DESC)); + CHECK(expect_names(view, name_descending, 5)); + CHECK(lardon3d_image_view_set_sort(view, LARDON3D_IMAGE_SORT_SIZE_ASC)); + CHECK(expect_names(view, size_ascending, 5)); + CHECK(lardon3d_image_view_set_sort(view, LARDON3D_IMAGE_SORT_SIZE_DESC)); + CHECK(expect_names(view, size_descending, 5)); + CHECK(!lardon3d_image_view_set_sort(view, (Lardon3DImageSort)99)); + + char error[256]; + CHECK(lardon3d_image_view_set_filter(view, "", error, sizeof(error))); + CHECK(lardon3d_image_view_count(view) == 5); + CHECK(lardon3d_image_view_set_filter(view, "JPG", error, sizeof(error))); + CHECK(lardon3d_image_view_count(view) == 2); + CHECK(lardon3d_image_view_total_size(view) == 10); + CHECK(lardon3d_image_view_ascii_contains("Alpha.JPEG", "pHa.j")); + CHECK(!lardon3d_image_view_ascii_contains("été.png", "ÉTÉ")); + CHECK(lardon3d_image_view_set_filter(view, "été", error, sizeof(error))); + CHECK(expect_names(view, (const char *const[]){"été.png"}, 1)); + CHECK(lardon3d_image_view_set_filter(view, "absent", error, sizeof(error))); + CHECK(lardon3d_image_view_count(view) == 0); + CHECK(lardon3d_image_view_selection(view) == 0); + CHECK(lardon3d_image_view_offset(view) == 0); + + CHECK(lardon3d_image_view_set_filter(view, "", error, sizeof(error))); + CHECK(lardon3d_image_view_set_sort(view, LARDON3D_IMAGE_SORT_IMPORT_ORDER)); + lardon3d_image_view_select(view, 2, 2); + size_t selected_catalog; + CHECK(lardon3d_image_view_catalog_index(view, 2, &selected_catalog)); + CHECK(selected_catalog == 2); + CHECK(!lardon3d_image_view_catalog_index(view, 99, &selected_catalog)); + CHECK(lardon3d_image_view_set_sort(view, LARDON3D_IMAGE_SORT_NAME_ASC)); + CHECK(strcmp( + lardon3d_image_view_get( + view, + lardon3d_image_view_selection(view) + )->filename, + "Beta.jpg" + ) == 0); + CHECK(lardon3d_image_view_set_filter(view, "beta", error, sizeof(error))); + CHECK(lardon3d_image_view_selection(view) == 0); + CHECK(lardon3d_image_view_set_filter(view, "alpha", error, sizeof(error))); + CHECK(lardon3d_image_view_selection(view) == 0); + CHECK(strcmp(lardon3d_image_view_get(view, 0)->filename, "alpha.png") == 0); + + char too_long[LARDON3D_IMAGE_FILTER_CAPACITY + 1]; + (void)memset(too_long, 'a', sizeof(too_long) - 1); + too_long[sizeof(too_long) - 1] = '\0'; + CHECK(!lardon3d_image_view_set_filter(view, too_long, error, sizeof(error))); + CHECK(error[0]); + CHECK(strcmp(lardon3d_image_view_filter(view), "alpha") == 0); + + for (size_t index = 0; index < 5; ++index) { + CHECK(lardon3d_image_catalog_get(catalog, index) == original_entries[index]); + CHECK(strcmp(original_entries[index]->filename, import_order[index]) == 0); + } + + char small_project[PATH_MAX]; + char small_images[PATH_MAX]; + char small_originals[PATH_MAX]; + char small_manifest[PATH_MAX]; + CHECK(join_path(small_project, base, "small")); + CHECK(join_path(small_images, small_project, "images")); + CHECK(join_path(small_originals, small_images, "originals")); + CHECK(join_path(small_manifest, small_images, "manifest.tsv")); + CHECK(mkdir(small_project, 0755) == 0); + CHECK(mkdir(small_images, 0755) == 0); + CHECK(mkdir(small_originals, 0755) == 0); + char only[PATH_MAX]; + CHECK(join_path(only, small_originals, "only.jpg")); + CHECK(create_file(only, 1)); + CHECK(write_manifest( + small_manifest, + "filename\tsize_bytes\tsource_path\nonly.jpg\t1\t/tmp/only.jpg\n" + )); + Lardon3DImageCatalog *small_catalog = load_catalog(small_project); + CHECK(small_catalog); + CHECK(lardon3d_image_view_set_filter(view, "", error, sizeof(error))); + CHECK(lardon3d_image_view_set_catalog(view, small_catalog)); + CHECK(lardon3d_image_view_count(view) == 1); + CHECK(strcmp(lardon3d_image_view_get(view, 0)->filename, "only.jpg") == 0); + lardon3d_image_view_select(view, 100, 3); + CHECK(lardon3d_image_view_selection(view) == 0); + CHECK(lardon3d_image_view_offset(view) == 0); + + lardon3d_image_view_destroy(view); + lardon3d_image_catalog_destroy(small_catalog); + lardon3d_image_catalog_destroy(catalog); + CHECK(remove_tree(base)); + return true; +} + +int +main(void) +{ + return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; +}