feat: add image catalog sorting and filtering

This commit is contained in:
fy59 2026-08-06 20:13:14 +02:00
parent 118172ad0e
commit 2fe594cc51
10 changed files with 1113 additions and 64 deletions

View file

@ -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.

View file

@ -3,9 +3,8 @@
#include <stdbool.h>
#include <limits.h>
#include <stddef.h>
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);

View file

@ -0,0 +1,71 @@
#ifndef LARDON3D_IMAGE_VIEW_H
#define LARDON3D_IMAGE_VIEW_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <lardon3d/image_catalog.h>
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

View file

@ -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)

View file

@ -4,6 +4,7 @@
#include <lardon3d/app.h>
#include <lardon3d/app_state.h>
#include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
#include <lardon3d/tui.h>
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();

436
src/image_view.c Normal file
View file

@ -0,0 +1,436 @@
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <lardon3d/image_view.h>
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);
}

View file

@ -4,6 +4,7 @@
#include <lardon3d/layout.h>
#include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
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);
}
}

View file

@ -11,6 +11,7 @@
#include <lardon3d/project.h>
#include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
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

263
src/tui.c
View file

@ -3,9 +3,11 @@
#include <ncurses.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <lardon3d/import_task.h>
#include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
#include <lardon3d/layout.h>
#include <lardon3d/project.h>
#include <lardon3d/tui.h>
@ -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) {

316
tests/test_image_view.c Normal file
View file

@ -0,0 +1,316 @@
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <lardon3d/app_state.h>
#include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
#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;
}