view infinite_canvas/dev_ui.c @ 278:8d560f50ed4c

Improve infinite canvas interactions and browser chrome Render Lucide icons directly with Raylib, add searchable icon browsing, robust text editing, entity lifecycle animations, z-order-safe input, semantic themes, and animated editable browser controls. Document rendering, pinning, context, and component extension for future agents. Co-authored-by: Copilot <[email protected]> Copilot-Session: f68442b1-fa8f-46a0-9689-81710613bbd4
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:16:14 -0700
parents b55c22cff335
children 49e9e591c9bb
line wrap: on
line source

#include "infinite_canvas/dev_ui.h"

#include <math.h>

#define RAYGUI_IMPLEMENTATION
#include "third_party/raylib/include/raygui.h"

static const Rectangle DEV_PANEL = {18.0f, 18.0f, 264.0f, 478.0f};

static Rectangle Canvas_Dev_UI_Context_Bounds(void)
{
    float width = 610.0f;
    float x = (float)GetScreenWidth() - width - 18.0f;
    if (x < DEV_PANEL.x + DEV_PANEL.width + 18.0f) {
        x = DEV_PANEL.x + DEV_PANEL.width + 18.0f;
        width = (float)GetScreenWidth() - x - 18.0f;
    }
    return (Rectangle){
        x,
        18.0f,
        width,
        (float)GetScreenHeight() - 36.0f,
    };
}

static float Canvas_Dev_UI_Draw_Wrapped_Context(
    const char *p_text,
    Font font,
    Rectangle bounds,
    float scroll,
    Color color)
{
    const float font_size = 13.0f;
    const float line_height = 19.0f;
    const float spacing = 0.0f;
    char line[256] = {0};
    size_t line_length = 0;
    float y = bounds.y - scroll;

    for (const char *p_cursor = p_text;; p_cursor++) {
        boolean at_end = *p_cursor == '\0';
        boolean at_newline = *p_cursor == '\n';
        if (!at_end && !at_newline) {
            line[line_length++] = *p_cursor;
            line[line_length] = '\0';
            if (line_length + 1 < sizeof(line) &&
                MeasureTextEx(font, line, font_size, spacing).x <= bounds.width) {
                continue;
            }
            if (line_length > 1) {
                char overflow = line[--line_length];
                line[line_length] = '\0';
                if (y + line_height >= bounds.y &&
                    y <= bounds.y + bounds.height) {
                    DrawTextEx(
                        font,
                        line,
                        (Vector2){bounds.x, y},
                        font_size,
                        spacing,
                        color);
                }
                y += line_height;
                line[0] = overflow;
                line[1] = '\0';
                line_length = 1;
            }
            continue;
        }

        if (line_length > 0 &&
            y + line_height >= bounds.y &&
            y <= bounds.y + bounds.height) {
            DrawTextEx(
                font,
                line,
                (Vector2){bounds.x, y},
                font_size,
                spacing,
                color);
        }
        y += line_height;
        line_length = 0;
        line[0] = '\0';
        if (at_end) break;
    }
    return y - bounds.y + scroll;
}

static void Canvas_Dev_UI_Draw_Context_Inspector(
    Canvas_Dev_UI *p_ui,
    const Canvas_Context_Snapshot *p_snapshot,
    Font font,
    const Canvas_Theme *p_theme)
{
    Rectangle panel = Canvas_Dev_UI_Context_Bounds();
    Canvas_Theme_Draw_Shadow(p_theme, panel, 0.04f, 14, 1.0f, 1.0f);
    DrawRectangleRounded(panel, 0.04f, 14, p_theme->surface);
    DrawRectangleRoundedLinesEx(panel, 0.04f, 14, 1.0f, p_theme->border);

    DrawTextEx(
        font,
        "Camera context",
        (Vector2){panel.x + 20.0f, panel.y + 18.0f},
        21.0f,
        0.0f,
        p_theme->text);
    DrawTextEx(
        font,
        TextFormat(
            "%d visible entities%s",
            (int)p_snapshot->visible_count,
            p_snapshot->truncated ? " - output truncated" : ""),
        (Vector2){panel.x + 20.0f, panel.y + 48.0f},
        13.0f,
        0.0f,
        p_snapshot->truncated ? p_theme->danger : p_theme->text_muted);

    Rectangle close = {
        panel.x + panel.width - 76.0f,
        panel.y + 16.0f,
        56.0f,
        30.0f,
    };
    if (GuiButtonRounded(close, "Close", 0.30f, 10)) {
        p_ui->context_open = FALSE;
        return;
    }

    Rectangle content = {
        panel.x + 20.0f,
        panel.y + 82.0f,
        panel.width - 40.0f,
        panel.height - 102.0f,
    };
    float max_scroll = fmaxf(0.0f, p_ui->context_content_height - content.height);
    if (CheckCollisionPointRec(GetMousePosition(), panel)) {
        p_ui->context_scroll -= GetMouseWheelMove() * 42.0f;
    }
    p_ui->context_scroll = fmaxf(0.0f, fminf(p_ui->context_scroll, max_scroll));

    BeginScissorMode(
        (int32)content.x,
        (int32)content.y,
        (int32)content.width,
        (int32)content.height);
    p_ui->context_content_height = Canvas_Dev_UI_Draw_Wrapped_Context(
        p_ui->context_buffer,
        font,
        content,
        p_ui->context_scroll,
        p_theme->text);
    EndScissorMode();

    max_scroll = fmaxf(0.0f, p_ui->context_content_height - content.height);
    if (max_scroll > 0.0f) {
        float track_height = content.height;
        float thumb_height = fmaxf(
            36.0f,
            track_height * content.height / p_ui->context_content_height);
        float thumb_y = content.y +
            (p_ui->context_scroll / max_scroll) * (track_height - thumb_height);
        DrawRectangleRounded(
            (Rectangle){
                panel.x + panel.width - 10.0f,
                thumb_y,
                4.0f,
                thumb_height,
            },
            0.5f,
            6,
            p_theme->text_muted);
    }
}

static void Canvas_Dev_UI_Set_Style(
    Font font,
    const Canvas_Theme *p_theme)
{
    GuiSetFont(font);
    GuiSetStyle(DEFAULT, TEXT_SIZE, 14);
    GuiSetStyle(DEFAULT, TEXT_SPACING, 0);
    GuiSetStyle(DEFAULT, BORDER_WIDTH, 1);
    GuiSetStyle(DEFAULT, BACKGROUND_COLOR, ColorToInt(p_theme->surface));

    int32 controls[] = {BUTTON, DROPDOWNBOX};
    for (size_t index = 0; index < sizeof(controls) / sizeof(controls[0]); index++) {
        int32 control = controls[index];
        GuiSetStyle(control, BORDER_WIDTH, 1);
        GuiSetStyle(control, BORDER_COLOR_NORMAL, ColorToInt(p_theme->border));
        GuiSetStyle(control, BASE_COLOR_NORMAL, ColorToInt(p_theme->surface_muted));
        GuiSetStyle(control, TEXT_COLOR_NORMAL, ColorToInt(p_theme->text));
        GuiSetStyle(control, BORDER_COLOR_FOCUSED, ColorToInt(p_theme->accent));
        GuiSetStyle(control, BASE_COLOR_FOCUSED, ColorToInt(p_theme->accent_soft));
        GuiSetStyle(control, TEXT_COLOR_FOCUSED, ColorToInt(p_theme->text));
        GuiSetStyle(control, BORDER_COLOR_PRESSED, ColorToInt(p_theme->accent));
        GuiSetStyle(control, BASE_COLOR_PRESSED, ColorToInt(p_theme->accent_soft));
        GuiSetStyle(control, TEXT_COLOR_PRESSED, ColorToInt(p_theme->accent));
    }
}

void Canvas_Dev_UI_Init(
    Canvas_Dev_UI *p_ui,
    Font font,
    const Canvas_Theme *p_theme)
{
    *p_ui = (Canvas_Dev_UI){
        .selected_type = CANVAS_ENTITY_RECTANGLE,
        .dropdown_open = FALSE,
        .context_open = FALSE,
    };
    Canvas_Dev_UI_Set_Style(font, p_theme);
}

boolean Canvas_Dev_UI_Contains_Pointer(const Canvas_Dev_UI *p_ui)
{
    // The expanded dropdown is modal: it draws outside DEV_PANEL and also
    // consumes outside clicks to close itself.
    if (p_ui->dropdown_open) return TRUE;

    Vector2 pointer = GetMousePosition();
    if (CheckCollisionPointRec(pointer, DEV_PANEL)) return TRUE;
    if (p_ui->context_open &&
        CheckCollisionPointRec(pointer, Canvas_Dev_UI_Context_Bounds())) {
        return TRUE;
    }
    return FALSE;
}

void Canvas_Dev_UI_Draw(
    Canvas_Dev_UI *p_ui,
    Canvas_Camera *p_camera,
    Canvas_Scene *p_scene,
    Font font,
    Canvas_Theme *p_theme)
{
    Canvas_Context_Snapshot context = Canvas_Scene_Build_Visible_Context(
        p_scene,
        p_camera,
        p_ui->context_buffer,
        sizeof(p_ui->context_buffer));

    Canvas_Theme_Draw_Shadow(p_theme, DEV_PANEL, 0.09f, 16, 1.0f, 1.0f);
    DrawRectangleRounded(DEV_PANEL, 0.09f, 16, p_theme->surface);
    DrawRectangleRoundedLinesEx(
        DEV_PANEL,
        0.09f,
        16,
        1.0f,
        p_theme->border);
    DrawTextEx(font, "Canvas", (Vector2){34.0f, 34.0f}, 21.0f, 0.0f, p_theme->text);
    DrawTextEx(
        font,
        "Developer controls",
        (Vector2){34.0f, 61.0f},
        12.0f,
        0.1f,
        p_theme->text_muted);

    if (p_ui->dropdown_open) GuiLock();

    if (GuiButtonRounded((Rectangle){34.0f, 126.0f, 112.0f, 34.0f}, "Add entity", 0.32f, 10)) {
        Canvas_Scene_Add(
            p_scene,
            (Canvas_Entity_Type)p_ui->selected_type,
            p_camera->target);
    }
    if (GuiButtonRounded((Rectangle){154.0f, 126.0f, 112.0f, 34.0f}, "Clear", 0.32f, 10)) {
        Canvas_Scene_Fade_Out_All(p_scene);
    }
    if (GuiButtonRounded((Rectangle){34.0f, 168.0f, 72.0f, 34.0f}, "Demo", 0.32f, 10)) {
        Canvas_Scene_Add_Demo(p_scene);
    }
    if (GuiButtonRounded((Rectangle){114.0f, 168.0f, 88.0f, 34.0f}, "Reset", 0.32f, 10)) {
        p_camera->target = (Vector2){0.0f, 0.0f};
        p_camera->zoom = 1.0f;
    }
    if (GuiButtonRounded(
            (Rectangle){210.0f, 168.0f, 56.0f, 34.0f},
            p_scene->show_grid ? "Grid" : "White",
            0.32f,
            10)) {
        p_scene->show_grid = !p_scene->show_grid;
    }
    if (GuiButtonRounded(
            (Rectangle){34.0f, 210.0f, 232.0f, 34.0f},
            "10 browser stress scene",
            0.32f,
            10)) {
        Canvas_Scene_Add_Browser_Grid(p_scene);
        p_camera->target = (Vector2){0.0f, 0.0f};
        p_camera->zoom = 1.0f;
    }

    const char *p_theme_label =
        p_theme->resolved_mode == CANVAS_THEME_DARK ?
            "Switch to light mode" :
            "Switch to dark mode";
    if (GuiButtonRounded(
            (Rectangle){34.0f, 252.0f, 232.0f, 34.0f},
            p_theme_label,
            0.32f,
            10)) {
        Canvas_Theme_Toggle(p_theme);
        Canvas_Dev_UI_Set_Style(font, p_theme);
    }

    if (GuiButtonRounded(
            (Rectangle){34.0f, 294.0f, 232.0f, 34.0f},
            TextFormat("View camera context (%d)", (int)context.visible_count),
            0.32f,
            10)) {
        p_ui->context_open = !p_ui->context_open;
        p_ui->context_scroll = 0.0f;
    }

    if (GuiButtonRounded(
            (Rectangle){34.0f, 336.0f, 232.0f, 34.0f},
            "Lucide icon gallery",
            0.32f,
            10)) {
        Canvas_Scene_Show_Lucide_Gallery(p_scene, p_camera);
    }

    DrawTextEx(font, TextFormat("%d FPS", GetFPS()), (Vector2){34.0f, 396.0f}, 12.0f, 0.0f, p_theme->text_muted);
    DrawTextEx(font, TextFormat("%.2fx zoom", p_camera->zoom), (Vector2){104.0f, 396.0f}, 12.0f, 0.0f, p_theme->text_muted);
    DrawTextEx(
        font,
        TextFormat("%d objects", (int)Dowa_Array_Length(p_scene->p_entities)),
        (Vector2){196.0f, 396.0f},
        12.0f,
        0.0f,
        p_theme->text_muted);
    DrawTextEx(
        font,
        "Drag objects directly on the canvas",
        (Vector2){34.0f, 442.0f},
        12.0f,
        0.0f,
        p_theme->text_muted);

    if (p_ui->context_open) {
        Canvas_Dev_UI_Draw_Context_Inspector(
            p_ui,
            &context,
            font,
            p_theme);
    }

    // Draw last so the expanded list overlays the other developer controls.
    if (p_ui->dropdown_open) GuiUnlock();
    Rectangle dropdown = {34.0f, 84.0f, 232.0f, 34.0f};
    if (GuiDropdownBoxRounded(
            dropdown,
            "Rectangle;Circle;Line;Text;Button;Text area;Dropdown;Accordion;Card;Calendar;Switch;Table;Notification;Scrollable area;Image;Web content",
            &p_ui->selected_type,
            p_ui->dropdown_open,
            0.32f,
            10)) {
        p_ui->dropdown_open = !p_ui->dropdown_open;
    }
}