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 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, et aux fichiers de `images/originals`. Les flèches, `j`/`k`, PageUp, PageDown,
Home et End naviguent dans la liste ; `R` recharge le catalogue. 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 <stdbool.h>
#include <limits.h> #include <limits.h>
#include <stddef.h>
typedef struct Lardon3DImageCatalog Lardon3DImageCatalog; typedef struct Lardon3DImageCatalog Lardon3DImageCatalog;
typedef struct Lardon3DImageView Lardon3DImageView;
typedef enum { typedef enum {
LARDON3D_SCREEN_HOME = 0, LARDON3D_SCREEN_HOME = 0,
@ -23,8 +22,7 @@ typedef struct {
char project_path[PATH_MAX]; char project_path[PATH_MAX];
char status_message[256]; char status_message[256];
Lardon3DImageCatalog *image_catalog; Lardon3DImageCatalog *image_catalog;
size_t image_selection; Lardon3DImageView *image_view;
size_t image_offset;
} Lardon3DAppState; } Lardon3DAppState;
void lardon3d_app_state_init(Lardon3DAppState *state); 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.c',
'src/import_task.c', 'src/import_task.c',
'src/image_catalog.c', 'src/image_catalog.c',
'src/image_view.c',
'src/project.c', 'src/project.c',
], ],
include_directories: include_directories('include'), include_directories: include_directories('include'),
@ -75,3 +76,16 @@ image_catalog_test = executable(
) )
test('image-catalog', image_catalog_test, timeout: 30) 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.h>
#include <lardon3d/app_state.h> #include <lardon3d/app_state.h>
#include <lardon3d/image_catalog.h> #include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
#include <lardon3d/tui.h> #include <lardon3d/tui.h>
int int
@ -21,6 +22,7 @@ lardon3d_app_run(void)
} }
bool success = lardon3d_tui_run(&state); bool success = lardon3d_tui_run(&state);
lardon3d_image_view_destroy(state.image_view);
lardon3d_image_catalog_destroy(state.image_catalog); lardon3d_image_catalog_destroy(state.image_catalog);
lardon3d_tui_shutdown(); 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/layout.h>
#include <lardon3d/image_catalog.h> #include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
enum { enum {
MINIMUM_ROWS = 20, MINIMUM_ROWS = 20,
@ -71,7 +72,7 @@ screen_texts(
case LARDON3D_SCREEN_IMPORT: case LARDON3D_SCREEN_IMPORT:
*title = "Import"; *title = "Import";
*content = "Import des images"; *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; break;
case LARDON3D_SCREEN_VIEWER: case LARDON3D_SCREEN_VIEWER:
*title = "Viewer"; *title = "Viewer";
@ -143,34 +144,67 @@ draw_catalog(
draw_text(7, 4, columns - 6, "Aucun projet chargé."); draw_text(7, 4, columns - 6, "Aucun projet chargé.");
return; 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]; char line[512];
(void)snprintf( (void)snprintf(
line, line,
sizeof(line), sizeof(line),
"Images importées : %zu", "Images visibles : %zu / %zu",
count count,
total
); );
draw_text(6, 4, columns - 6, line); draw_text(6, 4, columns - 6, line);
char size_text[64]; char size_text[64];
lardon3d_image_catalog_format_size( lardon3d_image_catalog_format_size(
lardon3d_image_catalog_total_size(state->image_catalog), lardon3d_image_view_total_size(state->image_view),
size_text, size_text,
sizeof(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); 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) { 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; return;
} }
int journal_row = rows - 7; 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) { for (size_t row = 0; row < visible; ++row) {
size_t index = state->image_offset + row; size_t index = offset + row;
const Lardon3DImageEntry *entry = lardon3d_image_catalog_get( const Lardon3DImageEntry *entry = lardon3d_image_view_get(
state->image_catalog, state->image_view,
index index
); );
if (!entry) { if (!entry) {
@ -185,11 +219,11 @@ draw_catalog(
line, line,
sizeof(line), sizeof(line),
"%c %s %s", "%c %s %s",
index == state->image_selection ? '>' : ' ', index == selection ? '>' : ' ',
entry->filename, entry->filename,
size_text 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/project.h>
#include <lardon3d/image_catalog.h> #include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
enum { enum {
MAX_CREATED_DIRECTORIES = 16, MAX_CREATED_DIRECTORIES = 16,
@ -36,10 +37,10 @@ set_status(Lardon3DAppState *state, const char *message)
static void static void
clear_catalog(Lardon3DAppState *state) clear_catalog(Lardon3DAppState *state)
{ {
lardon3d_image_view_destroy(state->image_view);
state->image_view = NULL;
lardon3d_image_catalog_destroy(state->image_catalog); lardon3d_image_catalog_destroy(state->image_catalog);
state->image_catalog = NULL; state->image_catalog = NULL;
state->image_selection = 0;
state->image_offset = 0;
} }
static bool static bool

263
src/tui.c
View file

@ -3,9 +3,11 @@
#include <ncurses.h> #include <ncurses.h>
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h> #include <stdio.h>
#include <string.h>
#include <lardon3d/import_task.h> #include <lardon3d/import_task.h>
#include <lardon3d/image_catalog.h> #include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
#include <lardon3d/layout.h> #include <lardon3d/layout.h>
#include <lardon3d/project.h> #include <lardon3d/project.h>
#include <lardon3d/tui.h> #include <lardon3d/tui.h>
@ -20,6 +22,7 @@ typedef enum {
INPUT_PROJECT_CREATE, INPUT_PROJECT_CREATE,
INPUT_PROJECT_OPEN, INPUT_PROJECT_OPEN,
INPUT_IMPORT_DIRECTORY, INPUT_IMPORT_DIRECTORY,
INPUT_IMAGE_FILTER,
} InputMode; } InputMode;
typedef struct { typedef struct {
@ -35,58 +38,108 @@ catalog_page_size(void)
int columns; int columns;
getmaxyx(stdscr, rows, columns); getmaxyx(stdscr, rows, columns);
(void)columns; (void)columns;
return rows > 16 ? (size_t)(rows - 16) : 1; return rows > 18 ? (size_t)(rows - 18) : 1;
} }
static void static void
normalize_catalog_navigation(Lardon3DAppState *state) normalize_view_navigation(Lardon3DAppState *state)
{ {
size_t count = lardon3d_image_catalog_count(state->image_catalog); lardon3d_image_view_normalize(state->image_view, catalog_page_size());
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;
}
} }
static bool static bool
reload_catalog(Lardon3DAppState *state, bool announce_success) 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); lardon3d_image_catalog_destroy(state->image_catalog);
state->image_catalog = NULL; state->image_catalog = NULL;
char message[sizeof(state->status_message)]; char message[sizeof(state->status_message)] = "";
state->image_catalog = lardon3d_image_catalog_load( state->image_catalog = lardon3d_image_catalog_load(
state, state,
message, message,
sizeof(message) sizeof(message)
); );
normalize_catalog_navigation(state); if (!state->image_catalog) {
if (!state->image_catalog || message[0]) {
(void)snprintf( (void)snprintf(
state->status_message, state->status_message,
sizeof(state->status_message), sizeof(state->status_message),
"%s", "%s",
message 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) { if (announce_success) {
(void)snprintf( (void)snprintf(
state->status_message, state->status_message,
sizeof(state->status_message), sizeof(state->status_message),
"Catalogue rechargé : %zu image%s.", "Catalogue rechargé : %zu image%s.",
lardon3d_image_catalog_count(state->image_catalog), lardon3d_image_view_count(state->image_view),
lardon3d_image_catalog_count(state->image_catalog) == 1 ? "" : "s" lardon3d_image_view_count(state->image_view) == 1 ? "" : "s"
); );
} }
return true; return true;
@ -109,6 +162,8 @@ redraw(
label = "Nom du dossier projet :"; label = "Nom du dossier projet :";
} else if (input->mode == INPUT_IMPORT_DIRECTORY) { } else if (input->mode == INPUT_IMPORT_DIRECTORY) {
label = "Dossier source :"; label = "Dossier source :";
} else if (input->mode == INPUT_IMAGE_FILTER) {
label = "Filtre :";
} }
Lardon3DImportTaskSnapshot snapshot; Lardon3DImportTaskSnapshot snapshot;
const Lardon3DImportTaskSnapshot *displayed_snapshot = NULL; const Lardon3DImportTaskSnapshot *displayed_snapshot = NULL;
@ -187,6 +242,8 @@ handle_active_input(
message = "Ouverture du projet annulée."; message = "Ouverture du projet annulée.";
} else if (input->mode == INPUT_IMPORT_DIRECTORY) { } else if (input->mode == INPUT_IMPORT_DIRECTORY) {
message = "Import annulé."; message = "Import annulé.";
} else if (input->mode == INPUT_IMAGE_FILTER) {
message = "Filtre annulé.";
} }
input->mode = INPUT_NONE; input->mode = INPUT_NONE;
(void)snprintf( (void)snprintf(
@ -205,6 +262,37 @@ handle_active_input(
} }
} else if (input->mode == INPUT_IMPORT_DIRECTORY) { } else if (input->mode == INPUT_IMPORT_DIRECTORY) {
(void)start_import_task(state, input, task); (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 { } else {
if (lardon3d_project_create(state, input->text)) { if (lardon3d_project_create(state, input->text)) {
(void)reload_catalog(state, false); (void)reload_catalog(state, false);
@ -223,7 +311,18 @@ handle_active_input(
} }
if (key >= 0 && key <= UCHAR_MAX && isprint((unsigned char)key)) { 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 (input->mode == INPUT_PROJECT_OPEN) {
if (lardon3d_project_open(state, input->text)) { if (lardon3d_project_open(state, input->text)) {
(void)reload_catalog(state, false); (void)reload_catalog(state, false);
@ -301,24 +400,31 @@ handle_normal_input(
state->screen = LARDON3D_SCREEN_HOME; state->screen = LARDON3D_SCREEN_HOME;
return true; return true;
case KEY_RESIZE: case KEY_RESIZE:
normalize_catalog_navigation(state); normalize_view_navigation(state);
return true; return true;
case KEY_UP: case KEY_UP:
case 'k': case 'k':
if (state->screen == LARDON3D_SCREEN_IMPORT if (state->screen == LARDON3D_SCREEN_IMPORT
&& state->image_selection > 0) { && lardon3d_image_view_selection(state->image_view) > 0) {
--state->image_selection; lardon3d_image_view_select(
normalize_catalog_navigation(state); state->image_view,
lardon3d_image_view_selection(state->image_view) - 1,
catalog_page_size()
);
return true; return true;
} }
return false; return false;
case KEY_DOWN: case KEY_DOWN:
case 'j': { 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 if (state->screen == LARDON3D_SCREEN_IMPORT
&& state->image_selection + 1 < count) { && selection < count && selection + 1 < count) {
++state->image_selection; lardon3d_image_view_select(
normalize_catalog_navigation(state); state->image_view,
selection + 1,
catalog_page_size()
);
return true; return true;
} }
return false; return false;
@ -326,39 +432,53 @@ handle_normal_input(
case KEY_PPAGE: case KEY_PPAGE:
if (state->screen == LARDON3D_SCREEN_IMPORT) { if (state->screen == LARDON3D_SCREEN_IMPORT) {
size_t page_size = catalog_page_size(); size_t page_size = catalog_page_size();
state->image_selection = state->image_selection > page_size size_t selection = lardon3d_image_view_selection(state->image_view);
? state->image_selection - page_size selection = selection > page_size
? selection - page_size
: 0; : 0;
normalize_catalog_navigation(state); lardon3d_image_view_select(state->image_view, selection, page_size);
return true; return true;
} }
return false; return false;
case KEY_NPAGE: case KEY_NPAGE:
if (state->screen == LARDON3D_SCREEN_IMPORT) { 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) { 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(); size_t page_size = catalog_page_size();
state->image_selection += remaining < page_size selection += remaining < page_size
? remaining ? remaining
: page_size; : page_size;
normalize_catalog_navigation(state); lardon3d_image_view_select(
state->image_view,
selection,
page_size
);
} }
return true; return true;
} }
return false; return false;
case KEY_HOME: case KEY_HOME:
if (state->screen == LARDON3D_SCREEN_IMPORT) { if (state->screen == LARDON3D_SCREEN_IMPORT) {
state->image_selection = 0; lardon3d_image_view_select(
normalize_catalog_navigation(state); state->image_view,
0,
catalog_page_size()
);
return true; return true;
} }
return false; return false;
case KEY_END: case KEY_END:
if (state->screen == LARDON3D_SCREEN_IMPORT) { 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);
state->image_selection = count > 0 ? count - 1 : 0; lardon3d_image_view_select(
normalize_catalog_navigation(state); state->image_view,
count > 0 ? count - 1 : 0,
catalog_page_size()
);
return true; return true;
} }
return false; return false;
@ -418,6 +538,59 @@ handle_normal_input(
return true; return true;
} }
return false; 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':
case 'C': case 'C':
if (state->screen == LARDON3D_SCREEN_PROJECTS) { 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;
}