diff infinite_canvas/canvas_test.c @ 276:b55c22cff335

Add interactive infinite canvas prototype
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 16:57:56 -0700
parents
children 8d560f50ed4c
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/infinite_canvas/canvas_test.c	Mon Aug 17 16:57:56 2026 -0700
@@ -0,0 +1,419 @@
+#include "infinite_canvas/canvas.h"
+
+#include <assert.h>
+#include <math.h>
+#include <stdio.h>
+#include <string.h>
+
+static boolean Near(float left, float right)
+{
+    return fabsf(left - right) < 0.001f ? TRUE : FALSE;
+}
+
+static Rectangle Entity_Bounds_For_Test(const Canvas_Entity *p_entity)
+{
+    if (p_entity->type == CANVAS_ENTITY_CIRCLE) {
+        return (Rectangle){
+            p_entity->position.x - p_entity->size.x,
+            p_entity->position.y - p_entity->size.x,
+            p_entity->size.x * 2.0f,
+            p_entity->size.x * 2.0f,
+        };
+    }
+    if (p_entity->type == CANVAS_ENTITY_LINE) {
+        float end_x = p_entity->position.x + p_entity->size.x;
+        float end_y = p_entity->position.y + p_entity->size.y;
+        return (Rectangle){
+            fminf(p_entity->position.x, end_x),
+            fminf(p_entity->position.y, end_y),
+            fabsf(p_entity->size.x),
+            fabsf(p_entity->size.y),
+        };
+    }
+    return (Rectangle){
+        p_entity->position.x,
+        p_entity->position.y,
+        p_entity->size.x,
+        p_entity->size.y,
+    };
+}
+
+static void Test_Camera_Round_Trip(void)
+{
+    Canvas_Camera camera;
+    Canvas_Camera_Init(&camera, 1200, 800);
+    camera.target = (Vector2){340.0f, -125.0f};
+    camera.zoom = 2.5f;
+
+    Vector2 world = {-22.0f, 718.0f};
+    Vector2 screen = Canvas_Camera_World_To_Screen(&camera, world);
+    Vector2 result = Canvas_Camera_Screen_To_World(&camera, screen);
+    assert(Near(result.x, world.x));
+    assert(Near(result.y, world.y));
+}
+
+static void Test_Theme_Resolution(void)
+{
+    Canvas_Theme light;
+    Canvas_Theme dark;
+    Canvas_Theme_Resolve(&light, CANVAS_THEME_LIGHT);
+    Canvas_Theme_Resolve(&dark, CANVAS_THEME_DARK);
+
+    assert(light.resolved_mode == CANVAS_THEME_LIGHT);
+    assert(light.background.r == 255);
+    assert(light.toast_surface.r == 255);
+    assert(dark.resolved_mode == CANVAS_THEME_DARK);
+    assert(dark.background.r == 15);
+    assert(dark.toast_surface.r == 24);
+    assert(Canvas_Theme_Mode_From_String("light") == CANVAS_THEME_LIGHT);
+    assert(Canvas_Theme_Mode_From_String("dark") == CANVAS_THEME_DARK);
+    assert(Canvas_Theme_Mode_From_String("invalid") == CANVAS_THEME_AUTO);
+
+    Canvas_Theme_Toggle(&light);
+    assert(light.resolved_mode == CANVAS_THEME_DARK);
+    assert(light.requested_mode == CANVAS_THEME_DARK);
+    Canvas_Theme_Toggle(&light);
+    assert(light.resolved_mode == CANVAS_THEME_LIGHT);
+}
+
+static void Test_Anchored_Zoom(void)
+{
+    Canvas_Camera camera;
+    Canvas_Camera_Init(&camera, 1000, 600);
+    camera.target = (Vector2){125.0f, -40.0f};
+    Vector2 cursor = {770.0f, 180.0f};
+    Vector2 before = Canvas_Camera_Screen_To_World(&camera, cursor);
+    Canvas_Camera_Zoom_At(&camera, cursor, 3.0f);
+    Vector2 after = Canvas_Camera_Screen_To_World(&camera, cursor);
+    assert(Near(before.x, after.x));
+    assert(Near(before.y, after.y));
+}
+
+static void Test_Viewport_Bounds(void)
+{
+    Canvas_Camera camera;
+    Canvas_Camera_Init(&camera, 200, 100);
+    camera.zoom = 2.0f;
+    Rectangle bounds = Canvas_Camera_Viewport_Bounds(&camera);
+    assert(Near(bounds.x, -50.0f));
+    assert(Near(bounds.y, -25.0f));
+    assert(Near(bounds.width, 100.0f));
+    assert(Near(bounds.height, 50.0f));
+}
+
+static void Test_Grid_Spacing(void)
+{
+    assert(Near(Canvas_Grid_Spacing(1.0f), 64.0f));
+    assert(Near(Canvas_Grid_Spacing(0.25f), 256.0f));
+    assert(Near(Canvas_Grid_Spacing(4.0f), 16.0f));
+}
+
+static void Test_Entity_Picking(void)
+{
+    Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE);
+    assert(p_arena);
+
+    Canvas_Scene scene;
+    Canvas_Scene_Init(&scene, p_arena);
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_RECTANGLE, (Vector2){10.0f, 20.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_CIRCLE, (Vector2){300.0f, 80.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_LINE, (Vector2){400.0f, 20.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_TEXT, (Vector2){650.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_BUTTON, (Vector2){850.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_TEXT_AREA, (Vector2){1050.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_DROPDOWN, (Vector2){1400.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_WEB_CONTENT, (Vector2){1700.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_ACCORDION, (Vector2){2500.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_CARD, (Vector2){2850.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_CALENDAR, (Vector2){3180.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_SWITCH, (Vector2){3510.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_TABLE, (Vector2){3650.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_NOTIFICATION, (Vector2){4120.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_SCROLL_AREA, (Vector2){4530.0f, 40.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_IMAGE, (Vector2){4900.0f, 40.0f}));
+
+    assert(Canvas_Scene_Pick(&scene, (Vector2){30.0f, 40.0f}, 1.0f) == 0);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){300.0f, 80.0f}, 1.0f) == 1);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){490.0f, 60.0f}, 1.0f) == 2);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){700.0f, 55.0f}, 1.0f) == 3);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){900.0f, 60.0f}, 1.0f) == 4);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){1100.0f, 80.0f}, 1.0f) == 5);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){1450.0f, 60.0f}, 1.0f) == 6);
+    scene.p_entities[6].active = TRUE;
+    assert(Canvas_Scene_Pick(&scene, (Vector2){1450.0f, 120.0f}, 1.0f) == 6);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){1450.0f, 190.0f}, 1.0f) == 6);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){1704.0f, 44.0f}, 1.0f) == 7);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){1750.0f, 140.0f}, 1.0f) == -1);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){2550.0f, 60.0f}, 1.0f) == 8);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){2900.0f, 100.0f}, 1.0f) == 9);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){3230.0f, 100.0f}, 1.0f) == 10);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){3540.0f, 58.0f}, 1.0f) == 11);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){3700.0f, 100.0f}, 1.0f) == 12);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){4170.0f, 80.0f}, 1.0f) == 13);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){4580.0f, 100.0f}, 1.0f) == 14);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){4950.0f, 100.0f}, 1.0f) == 15);
+    assert(Canvas_Scene_Pick(&scene, (Vector2){5500.0f, 500.0f}, 1.0f) == -1);
+
+    Dowa_Arena_Free(p_arena);
+}
+
+static void Test_New_Primitive_Defaults(void)
+{
+    Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE);
+    assert(p_arena);
+
+    Canvas_Scene scene;
+    Canvas_Scene_Init(&scene, p_arena);
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_ACCORDION, (Vector2){0.0f, 0.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_CARD, (Vector2){0.0f, 0.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_CALENDAR, (Vector2){0.0f, 0.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_SWITCH, (Vector2){0.0f, 0.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_TABLE, (Vector2){0.0f, 0.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_NOTIFICATION, (Vector2){0.0f, 0.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_SCROLL_AREA, (Vector2){0.0f, 0.0f}));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_IMAGE, (Vector2){0.0f, 0.0f}));
+
+    assert(Near(scene.p_entities[0].size.x, 300.0f));
+    assert(Near(scene.p_entities[0].size.y, 56.0f));
+    assert(Near(scene.p_entities[1].size.x, 280.0f));
+    assert(Near(scene.p_entities[1].size.y, 220.0f));
+    assert(scene.p_entities[2].value == 17);
+    assert(Near(scene.p_entities[3].size.x, 64.0f));
+    assert(Near(scene.p_entities[3].size.y, 36.0f));
+    assert(Near(scene.p_entities[4].size.x, 420.0f));
+    assert(scene.p_entities[4].value == 1);
+    assert(scene.p_entities[5].active);
+    assert(Near(scene.p_entities[6].size.y, 220.0f));
+    assert(strstr(scene.p_entities[7].text, "image-placeholder.svg"));
+    assert(strcmp(Canvas_Entity_Type_Name(CANVAS_ENTITY_ACCORDION), "Accordion") == 0);
+    assert(strcmp(Canvas_Entity_Type_Name(CANVAS_ENTITY_CARD), "Card") == 0);
+    assert(strcmp(Canvas_Entity_Type_Name(CANVAS_ENTITY_CALENDAR), "Calendar") == 0);
+    assert(strcmp(Canvas_Entity_Type_Name(CANVAS_ENTITY_SWITCH), "Switch") == 0);
+    assert(strcmp(Canvas_Entity_Type_Name(CANVAS_ENTITY_TABLE), "Table") == 0);
+    assert(strcmp(Canvas_Entity_Type_Name(CANVAS_ENTITY_NOTIFICATION), "Notification") == 0);
+    assert(strcmp(Canvas_Entity_Type_Name(CANVAS_ENTITY_SCROLL_AREA), "Scrollable area") == 0);
+    assert(strcmp(Canvas_Entity_Type_Name(CANVAS_ENTITY_IMAGE), "Image") == 0);
+
+    Dowa_Arena_Free(p_arena);
+}
+
+static void Test_Text_Editing_Focus(void)
+{
+    Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE);
+    assert(p_arena);
+
+    Canvas_Scene scene;
+    Canvas_Scene_Init(&scene, p_arena);
+    assert(!Canvas_Scene_Is_Text_Editing(&scene));
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_TEXT_AREA, (Vector2){0.0f, 0.0f}));
+
+    scene.selected_index = 0;
+    assert(!Canvas_Scene_Is_Text_Editing(&scene));
+    scene.p_entities[0].active = TRUE;
+    assert(Canvas_Scene_Is_Text_Editing(&scene));
+    scene.p_entities[0].active = FALSE;
+    assert(!Canvas_Scene_Is_Text_Editing(&scene));
+
+    Dowa_Arena_Free(p_arena);
+}
+
+static void Test_Entity_Ids_Remain_Unique_After_Clear(void)
+{
+    Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE);
+    assert(p_arena);
+
+    Canvas_Scene scene;
+    Canvas_Scene_Init(&scene, p_arena);
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_WEB_CONTENT, (Vector2){0.0f, 0.0f}));
+    uint32 first_id = scene.p_entities[0].id;
+    Canvas_Scene_Clear(&scene);
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_WEB_CONTENT, (Vector2){0.0f, 0.0f}));
+    assert(scene.p_entities[0].id != first_id);
+
+    Dowa_Arena_Free(p_arena);
+}
+
+static void Test_Browser_Grid(void)
+{
+    Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE);
+    assert(p_arena);
+
+    Canvas_Scene scene;
+    Canvas_Scene_Init(&scene, p_arena);
+    Canvas_Scene_Add_Browser_Grid(&scene);
+    assert(Dowa_Array_Length(scene.p_entities) == 10);
+    for (size_t index = 0; index < Dowa_Array_Length(scene.p_entities); index++) {
+        assert(scene.p_entities[index].type == CANVAS_ENTITY_WEB_CONTENT);
+        assert(Near(scene.p_entities[index].size.x, 236.0f));
+        assert(Near(scene.p_entities[index].size.y, 176.0f));
+    }
+
+    Dowa_Arena_Free(p_arena);
+}
+
+static void Test_Demo_Contains_Every_Entity_Type(void)
+{
+    Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE);
+    assert(p_arena);
+
+    Canvas_Scene scene;
+    Canvas_Scene_Init(&scene, p_arena);
+    Canvas_Scene_Add_Demo(&scene);
+    assert(Dowa_Array_Length(scene.p_entities) == CANVAS_ENTITY_TYPE_COUNT + 2);
+
+    int32 counts[CANVAS_ENTITY_TYPE_COUNT] = {0};
+    for (size_t index = 0; index < Dowa_Array_Length(scene.p_entities); index++) {
+        counts[scene.p_entities[index].type]++;
+    }
+    for (int32 type = 0; type < CANVAS_ENTITY_TYPE_COUNT; type++) {
+        int32 expected = type == CANVAS_ENTITY_NOTIFICATION ? 3 : 1;
+        assert(counts[type] == expected);
+    }
+    for (size_t index = 0; index < Dowa_Array_Length(scene.p_entities); index++) {
+        if (scene.p_entities[index].type == CANVAS_ENTITY_NOTIFICATION) continue;
+        Rectangle bounds = Entity_Bounds_For_Test(&scene.p_entities[index]);
+        assert(bounds.x >= -600.0f);
+        assert(bounds.y >= -600.0f);
+        assert(bounds.x + bounds.width <= 960.0f);
+        assert(bounds.y + bounds.height <= 600.0f);
+        for (size_t other = index + 1;
+             other < Dowa_Array_Length(scene.p_entities);
+             other++) {
+            if (scene.p_entities[other].type == CANVAS_ENTITY_NOTIFICATION) continue;
+            Rectangle other_bounds = Entity_Bounds_For_Test(&scene.p_entities[other]);
+            assert(!CheckCollisionRecs(bounds, other_bounds));
+        }
+    }
+
+    Dowa_Arena_Free(p_arena);
+}
+
+static void Test_Pinned_Entity_Stays_In_Screen_Space(void)
+{
+    Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE);
+    assert(p_arena);
+
+    Canvas_Camera camera;
+    Canvas_Camera_Init(&camera, 1000, 600);
+    Canvas_Scene scene;
+    Canvas_Scene_Init(&scene, p_arena);
+    assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_RECTANGLE, (Vector2){80.0f, 40.0f}));
+    scene.selected_index = 0;
+    Canvas_Scene_Toggle_Selected_Pin(&scene, &camera);
+    assert(scene.p_entities[0].pinned);
+
+    Vector2 original_screen =
+        Canvas_Camera_World_To_Screen(&camera, scene.p_entities[0].position);
+    Vector2 original_size = {
+        scene.p_entities[0].size.x * camera.zoom,
+        scene.p_entities[0].size.y * camera.zoom,
+    };
+    camera.target = (Vector2){900.0f, -500.0f};
+    camera.zoom = 3.0f;
+    Canvas_Scene_Sync_Pinned(&scene, &camera);
+
+    Vector2 pinned_screen =
+        Canvas_Camera_World_To_Screen(&camera, scene.p_entities[0].position);
+    assert(Near(pinned_screen.x, original_screen.x));
+    assert(Near(pinned_screen.y, original_screen.y));
+    assert(Near(scene.p_entities[0].size.x * camera.zoom, original_size.x));
+    assert(Near(scene.p_entities[0].size.y * camera.zoom, original_size.y));
+
+    Canvas_Scene_Toggle_Selected_Pin(&scene, &camera);
+    assert(!scene.p_entities[0].pinned);
+    Dowa_Arena_Free(p_arena);
+}
+
+static void Test_Visible_Context_Tracks_Camera_And_Values(void)
+{
+    Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE);
+    assert(p_arena);
+
+    Canvas_Camera camera;
+    Canvas_Camera_Init(&camera, 200, 100);
+    Canvas_Scene scene;
+    Canvas_Scene_Init(&scene, p_arena);
+    assert(Canvas_Scene_Add(
+        &scene,
+        CANVAS_ENTITY_TEXT_AREA,
+        (Vector2){-80.0f, -30.0f}));
+    assert(Canvas_Scene_Add(
+        &scene,
+        CANVAS_ENTITY_DROPDOWN,
+        (Vector2){250.0f, -20.0f}));
+    assert(Canvas_Scene_Add(
+        &scene,
+        CANVAS_ENTITY_SWITCH,
+        (Vector2){700.0f, 0.0f}));
+
+    snprintf(scene.p_entities[0].text, sizeof(scene.p_entities[0].text), "Visible note");
+    scene.p_entities[0].active = TRUE;
+    scene.p_entities[1].value = 2;
+    scene.p_entities[1].active = TRUE;
+    scene.p_entities[2].active = TRUE;
+
+    char context[4096];
+    Canvas_Context_Snapshot snapshot = Canvas_Scene_Build_Visible_Context(
+        &scene,
+        &camera,
+        context,
+        sizeof(context));
+    assert(snapshot.visible_count == 1);
+    assert(!snapshot.truncated);
+    assert(strstr(context, "Visible note"));
+    assert(strstr(context, "editing=true"));
+    assert(!strstr(context, "Research"));
+    assert(!strstr(context, "checked=true"));
+
+    camera.zoom = 0.25f;
+    snapshot = Canvas_Scene_Build_Visible_Context(
+        &scene,
+        &camera,
+        context,
+        sizeof(context));
+    assert(snapshot.visible_count == 2);
+    assert(strstr(context, "selected=\"Research\""));
+    assert(strstr(context, "open=true"));
+    assert(!strstr(context, "checked=true"));
+
+    camera.target.x = 900.0f;
+    snapshot = Canvas_Scene_Build_Visible_Context(
+        &scene,
+        &camera,
+        context,
+        sizeof(context));
+    assert(snapshot.visible_count == 1);
+    assert(strstr(context, "checked=true"));
+    assert(!strstr(context, "Visible note"));
+
+    char small_context[24];
+    snapshot = Canvas_Scene_Build_Visible_Context(
+        &scene,
+        &camera,
+        small_context,
+        sizeof(small_context));
+    assert(snapshot.visible_count == 1);
+    assert(snapshot.truncated);
+    assert(small_context[sizeof(small_context) - 1] == '\0');
+
+    Dowa_Arena_Free(p_arena);
+}
+
+int main(void)
+{
+    Test_Theme_Resolution();
+    Test_Camera_Round_Trip();
+    Test_Anchored_Zoom();
+    Test_Viewport_Bounds();
+    Test_Grid_Spacing();
+    Test_Entity_Picking();
+    Test_New_Primitive_Defaults();
+    Test_Text_Editing_Focus();
+    Test_Entity_Ids_Remain_Unique_After_Clear();
+    Test_Browser_Grid();
+    Test_Demo_Contains_Every_Entity_Type();
+    Test_Pinned_Entity_Stays_In_Screen_Space();
+    Test_Visible_Context_Tracks_Camera_And_Values();
+    printf("Infinite canvas tests passed.\n");
+    return 0;
+}