#include "render.h"
#include "clay.h"
#include "theme.h"
#include "fonts.h"
#include "inline_parse.h"
#include "wrap.h"
#include "hittest.h"
#include "markdown_io.h"
#include "lang.h"
#include "block_type_menu.h"
#include "image_cache.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <math.h>

typedef enum { RK_PLAIN, RK_BOLD, RK_ITALIC, RK_BOLD_ITALIC, RK_CODE, RK_LINK, RK_MARKER,
               RK_STRIKE, RK_HIGHLIGHT, RK_SUB, RK_SUPER } RenderKind;

typedef struct {
    RenderKind kind;
    int start, end; /* byte range in the block's raw text to actually display */
} RenderRun;

typedef struct {
    SDL2_Font *fonts;
    LayoutCache *cache;
    float contentWidth;
    int cursorBlock;
    int cursorOffset;
    int *toggledTaskBlock; /* set to a block index this frame if its task checkbox was clicked */
    bool mouseLeftPressed; /* this frame's left-click edge; Clay has no input polling under SDL2 */
    float winH; /* current window height in pixels -- used to flip block-type popups upward when
                   there isn't enough room to open them downward */
    SDL_Renderer *renderer;   /* needed by image_cache_get to create textures */
    ImageCache *imageCache;
    const char *docFilePath;  /* ed->filePath -- relative image paths resolve against its dir */
} RenderCtx;

/* Segments that need a strikethrough line drawn through them once their final screen position
   is known (after Clay_EndLayout) -- either a RUN_STRIKE (~~x~~) span, or every segment of a
   checked task-list item's whole line. Reset each frame. */
#define MAX_STRIKE_MARKS 512
typedef struct { int segIdx; Clay_Color color; } StrikeMark;
static StrikeMark g_strikeMarks[MAX_STRIKE_MARKS];
static int g_strikeMarkCount;

static void mark_strike(int segIdx, Clay_Color color) {
    if (g_strikeMarkCount >= MAX_STRIKE_MARKS) return;
    g_strikeMarks[g_strikeMarkCount].segIdx = segIdx;
    g_strikeMarks[g_strikeMarkCount].color = color;
    g_strikeMarkCount++;
}

typedef struct {
    SDL2_Font *fonts;
    RenderRun *runs;
    bool boldBase;
    int baseFontSize;
} MeasureCtx;

/* Small per-frame bump allocator for text that Clay needs to keep pointing at after this
   function returns (e.g. a formatted "3." list marker) — Clay stores the Clay_String's
   pointer, not a copy, so it must stay valid until this frame's render commands are drawn. */
#define SCRATCH_SIZE (64 * 1024)
static char g_scratch[SCRATCH_SIZE];
static int g_scratchPos;

/* Remembers the cursor position as of the last frame, so the "scroll the caret into view"
   check (see render_frame) only fires on the frame the cursor actually moved -- otherwise it
   would fight a free mouse-wheel scroll away from the cursor every single frame. */
static int s_lastScrollCursorBlock = -1, s_lastScrollCursorOffset = -1;

static char *scratch_alloc(int n) {
    if (g_scratchPos + n > SCRATCH_SIZE) n = SCRATCH_SIZE - g_scratchPos;
    if (n < 0) n = 0;
    char *p = g_scratch + g_scratchPos;
    g_scratchPos += n;
    return p;
}

static void resolve_style(RenderKind kind, bool boldBase, int baseFontSize, Clay_Color baseColor,
                           int *fontId, int *fontSize, Clay_Color *color) {
    bool mono = (kind == RK_CODE);
    bool bold = boldBase || kind == RK_BOLD || kind == RK_BOLD_ITALIC;
    bool italic = kind == RK_ITALIC || kind == RK_BOLD_ITALIC;
    if (kind == RK_MARKER) { mono = false; bold = false; italic = false; }
    *fontId = font_index(mono, bold, italic);
    *fontSize = baseFontSize;
    /* Approximated as smaller text, vertically centered like every other segment on the line --
       true baseline raise/lower isn't supported by the current per-line layout. */
    if (kind == RK_SUB || kind == RK_SUPER) *fontSize = (baseFontSize * 7) / 10;
    switch (kind) {
        case RK_MARKER: *color = COL_MARKER; break;
        case RK_LINK:   *color = COL_LINK; break;
        case RK_CODE:   *color = COL_CODE_TEXT; break;
        default:        *color = baseColor; break;
    }
}

static float measure_cb(void *ctxV, int runIndex, const char *text, int start, int len) {
    if (len <= 0) return 0.0f;
    MeasureCtx *ctx = (MeasureCtx *)ctxV;
    RenderKind kind = ctx->runs[runIndex].kind;
    int fontId, fontSize; Clay_Color color;
    resolve_style(kind, ctx->boldBase, ctx->baseFontSize, g_theme.text, &fontId, &fontSize, &color);

    char buf[1024];
    int n = len;
    if (n >= (int)sizeof(buf)) n = sizeof(buf) - 1;
    memcpy(buf, text + start, (size_t)n);
    buf[n] = '\0';
    TTF_Font *font = ctx->fonts[fontId].font;
    TTF_SetFontSize(font, fontSize);
    int w = 0, h = 0;
    TTF_SizeUTF8(font, buf, &w, &h);
    return (float)w;
}

/* Expands parsed inline runs into RenderRuns: styled markup is shown as plain rendered text
   with markers hidden, unless the cursor sits inside that run, in which case the raw markers
   are revealed (dimmed) around the still-styled content — WYSIWYG with an editable escape hatch. */
static void expand_runs(InlineRunList *runs, int cursorBlock, int cursorOffset, int thisBlockIndex,
                         RenderRun **outArr, int *outCount) {
    int cap = runs->count * 3 + 1;
    RenderRun *arr = malloc((size_t)cap * sizeof(RenderRun));
    int n = 0;
    bool hasCursor = (thisBlockIndex == cursorBlock);

    for (int i = 0; i < runs->count; i++) {
        InlineRun *r = &runs->runs[i];
        if (r->kind == RUN_PLAIN) {
            arr[n++] = (RenderRun){ RK_PLAIN, r->contentStart, r->contentEnd };
            continue;
        }
        if (r->kind == RUN_CODE) {
            /* Inline code markers are always hidden; the chip background carries the meaning. */
            arr[n++] = (RenderRun){ RK_CODE, r->contentStart, r->contentEnd };
            continue;
        }

        RenderKind styleKind = r->kind == RUN_BOLD ? RK_BOLD
                              : r->kind == RUN_ITALIC ? RK_ITALIC
                              : r->kind == RUN_BOLD_ITALIC ? RK_BOLD_ITALIC
                              : r->kind == RUN_STRIKE ? RK_STRIKE
                              : r->kind == RUN_HIGHLIGHT ? RK_HIGHLIGHT
                              : r->kind == RUN_SUB ? RK_SUB
                              : r->kind == RUN_SUPER ? RK_SUPER
                              : RK_LINK;
        bool focused = hasCursor && cursorOffset >= r->rawStart && cursorOffset <= r->rawEnd;
        if (!focused) {
            arr[n++] = (RenderRun){ styleKind, r->contentStart, r->contentEnd };
        } else {
            if (r->contentStart > r->rawStart) arr[n++] = (RenderRun){ RK_MARKER, r->rawStart, r->contentStart };
            arr[n++] = (RenderRun){ styleKind, r->contentStart, r->contentEnd };
            if (r->rawEnd > r->contentEnd) arr[n++] = (RenderRun){ RK_MARKER, r->contentEnd, r->rawEnd };
        }
    }

    *outArr = arr;
    *outCount = n;
}

static int compute_list_number(Document *doc, int blockIndex) {
    int n = 1;
    int i = blockIndex - 1;
    while (i >= 0 && doc->blocks[i].type == BLOCK_NUMBERED) { n++; i--; }
    return n;
}

static void emit_wrapped_lines(RenderCtx *ctx, int blockIndex, const char *text, RenderRun *runs, int runCount,
                                float availWidth, int baseFontSize, bool boldBase, Clay_Color baseColor, bool forceStrike,
                                Clay_LayoutAlignmentX alignX) {
    WrapRunSpan *spans = malloc((size_t)(runCount > 0 ? runCount : 1) * sizeof(WrapRunSpan));
    for (int i = 0; i < runCount; i++) { spans[i].start = runs[i].start; spans[i].end = runs[i].end; }

    MeasureCtx mctx = { ctx->fonts, runs, boldBase, baseFontSize };
    WrapResult wr;
    wrap_layout(text, spans, runCount, availWidth, measure_cb, &mctx, &wr);
    free(spans);

    float lineHeight = (float)baseFontSize * LINE_HEIGHT_MULT;

    for (int li = 0; li < wr.count; li++) {
        WrapLine *wl = &wr.lines[li];
        int srcStart = 0, srcEnd = 0;
        if (wl->count > 0) { srcStart = wl->segs[0].start; srcEnd = wl->segs[wl->count - 1].end; }

        int lineIdx = layout_cache_add_line(ctx->cache, blockIndex, li, srcStart, srcEnd);

        CLAY(CLAY_IDI("Line", lineIdx), {
            .layout = {
                .layoutDirection = CLAY_LEFT_TO_RIGHT,
                .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(lineHeight) },
                .childAlignment = { .x = alignX, .y = CLAY_ALIGN_Y_CENTER },
            },
        }) {
            for (int si = 0; si < wl->count; si++) {
                WrapSegment *ws = &wl->segs[si];
                RenderKind kind = runs[ws->runIndex].kind;
                int fontId, fontSize; Clay_Color color;
                resolve_style(kind, boldBase, baseFontSize, baseColor, &fontId, &fontSize, &color);

                int segIdx = layout_cache_add_seg(ctx->cache, lineIdx, ws->start, ws->end, fontId, fontSize);
                Clay_String txt = { .isStaticallyAllocated = false, .length = ws->end - ws->start, .chars = text + ws->start };
                Clay_TextElementConfig tc = { .fontId = (uint16_t)fontId, .fontSize = (uint16_t)fontSize, .textColor = color, .wrapMode = CLAY_TEXT_WRAP_NONE };

                if (kind == RK_STRIKE || forceStrike) mark_strike(segIdx, color);

                if (kind == RK_CODE) {
                    CLAY(CLAY_IDI("Seg", segIdx), {
                        .layout = { .padding = { 5, 5, 1, 1 } },
                        .backgroundColor = COL_CODE_BG,
                        .cornerRadius = CLAY_CORNER_RADIUS(4),
                    }) {
                        CLAY_TEXT(txt, tc);
                    }
                } else if (kind == RK_HIGHLIGHT) {
                    CLAY(CLAY_IDI("Seg", segIdx), {
                        .layout = { .padding = { 2, 2, 0, 0 } },
                        .backgroundColor = COL_HIGHLIGHT_BG,
                        .cornerRadius = CLAY_CORNER_RADIUS(2),
                    }) {
                        CLAY_TEXT(txt, tc);
                    }
                } else {
                    CLAY(CLAY_IDI("Seg", segIdx), {
                        .layout = { .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) } },
                    }) {
                        CLAY_TEXT(txt, tc);
                    }
                }
            }
        }
        layout_cache_close_line(ctx->cache, lineIdx);
    }

    wrap_result_free(&wr);
}

static void emit_code_block(RenderCtx *ctx, int blockIndex, const char *text, int len) {
    int fontId = FONT_MONO_REGULAR;
    int fontSize = theme_scaled_font_size(FS_CODE);
    float lineHeight = (float)fontSize * LINE_HEIGHT_MULT;
    int lineInBlock = 0;
    int pos = 0;

    for (;;) {
        int start = pos;
        while (pos < len && text[pos] != '\n') pos++;
        int end = pos;

        int lineIdx = layout_cache_add_line(ctx->cache, blockIndex, lineInBlock, start, end);
        CLAY(CLAY_IDI("Line", lineIdx), {
            .layout = {
                .layoutDirection = CLAY_LEFT_TO_RIGHT,
                .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(lineHeight) },
                .childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
            },
        }) {
            if (end > start) {
                int segIdx = layout_cache_add_seg(ctx->cache, lineIdx, start, end, fontId, fontSize);
                Clay_String txt = { .isStaticallyAllocated = false, .length = end - start, .chars = text + start };
                CLAY(CLAY_IDI("Seg", segIdx), {
                    .layout = { .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) } },
                }) {
                    CLAY_TEXT(txt, CLAY_TEXT_CONFIG({ .fontId = (uint16_t)fontId, .fontSize = (uint16_t)fontSize, .textColor = COL_CODEBLOCK_TEXT, .wrapMode = CLAY_TEXT_WRAP_NONE }));
                }
            }
        }
        layout_cache_close_line(ctx->cache, lineIdx);

        if (pos >= len) break;
        pos++;
        lineInBlock++;
    }
}

/* Renders one GFM table as a real grid: the run of `rows*cols` BLOCK_TABLE_HEADER_CELL/
   BLOCK_TABLE_CELL blocks starting at `startIndex` (row-major, per the document model -- see
   document.h). Equal-width columns (no cross-row content-proportional sizing -- Clay has no
   primitive for that within one layout pass, and a real content-measuring pre-pass is
   deliberately out of scope for v1). Each cell reuses the exact same inline_parse/expand_runs/
   emit_wrapped_lines pipeline a plain paragraph already uses, so cell text is fully
   cursor-navigable/selectable/editable through the existing hit-test machinery -- no new
   rendering primitives, just the existing per-block pipeline run once per cell instead of once
   per block. */
static void emit_table(RenderCtx *ctx, Document *doc, int startIndex, int rows, int cols) {
    int baseFontSize = theme_scaled_font_size(FS_BODY);
    float cellWidth = ctx->contentWidth / (float)cols;

    /* "Table", not "Block": the first header cell's own CLAY_IDI("Block", cellIndex) already
       equals CLAY_IDI("Block", startIndex) for r=0,c=0, so reusing "Block" here for the outer
       wrapper would declare that same ID twice in one layout. Nothing looks up a table's outer
       wrapper by ID today (Turn Into/quick-insert, the only "Block"+cursorBlock lookups, are both
       suppressed while the cursor is in a table cell), so a distinct namespace is free to use. */
    CLAY(CLAY_IDI("Table", startIndex), {
        .layout = {
            .layoutDirection = CLAY_TOP_TO_BOTTOM,
            .sizing = { CLAY_SIZING_FIXED(ctx->contentWidth), CLAY_SIZING_FIT(0) },
            .padding = { 0, 0, 8, BLOCK_GAP },
        },
        .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_ALL(1) },
    }) {
        for (int r = 0; r < rows; r++) {
            bool isHeaderRow = (r == 0);
            CLAY_AUTO_ID({
                .layout = { .layoutDirection = CLAY_LEFT_TO_RIGHT, .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) } },
                .backgroundColor = isHeaderRow ? COL_CODEBLOCK_BG : (Clay_Color){0, 0, 0, 0},
                .border = { .color = COL_MODAL_BORDER, .width = { .betweenChildren = 1 } },
            }) {
                for (int c = 0; c < cols; c++) {
                    int cellIndex = startIndex + r * cols + c;
                    Block *cell = &doc->blocks[cellIndex];

                    Clay_LayoutAlignmentX alignX = CLAY_ALIGN_X_LEFT;
                    if (cell->tableAlign == 'c') alignX = CLAY_ALIGN_X_CENTER;
                    else if (cell->tableAlign == 'r') alignX = CLAY_ALIGN_X_RIGHT;

                    CLAY(CLAY_IDI("Block", cellIndex), {
                        .layout = {
                            .layoutDirection = CLAY_TOP_TO_BOTTOM,
                            /* GROW (not FIT) height: when one cell in a row wraps to more lines
                               than its neighbors, every cell must stretch to the row's full
                               (tallest-cell-determined) height, or the shorter cells' borders/
                               background end early partway down the row instead of spanning it.
                               childAlignment.y centers the (possibly shorter-than-the-row) text
                               within that grown height, instead of it hugging the top edge. */
                            .sizing = { CLAY_SIZING_PERCENT(1.0f / (float)cols), CLAY_SIZING_GROW(0) },
                            .padding = { 8, 8, 6, 6 },
                            .childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
                        },
                    }) {
                        InlineRunList runs;
                        inline_parse(cell->text.data, cell->text.len, &runs);
                        RenderRun *rr; int rc;
                        expand_runs(&runs, ctx->cursorBlock, ctx->cursorOffset, cellIndex, &rr, &rc);
                        inline_runs_free(&runs);

                        emit_wrapped_lines(ctx, cellIndex, cell->text.data, rr, rc, cellWidth - 16.0f,
                                            baseFontSize, isHeaderRow, g_theme.text, false, alignX);
                        free(rr);
                    }
                }
            }
        }
    }
}

/* Renders one block that ISN'T a table cell -- BLOCK_TABLE_HEADER_CELL/BLOCK_TABLE_CELL are
   always consumed in a batch by emit_table (see render_frame's main loop) and should never
   reach this function individually under normal operation; if one somehow does (a corrupted
   table run), it falls into the generic paragraph-text path below rather than crashing. */
static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
    Block *b = &doc->blocks[blockIndex];

    int baseFontSize = FS_BODY;
    bool boldBase = false;
    Clay_Color baseColor = g_theme.text;
    int topPad = 3;

    switch (b->type) {
        case BLOCK_H1: baseFontSize = FS_H1; boldBase = true; topPad = 22; break;
        case BLOCK_H2: baseFontSize = FS_H2; boldBase = true; topPad = 16; break;
        case BLOCK_H3: baseFontSize = FS_H3; boldBase = true; topPad = 10; break;
        case BLOCK_H4: baseFontSize = FS_H4; boldBase = true; topPad = 8; break;
        case BLOCK_H5: baseFontSize = FS_H5; boldBase = true; topPad = 6; break;
        case BLOCK_H6: baseFontSize = FS_H6; boldBase = true; topPad = 6; baseColor = COL_H6_TEXT; break;
        case BLOCK_QUOTE: baseColor = COL_TEXT_QUOTE; break;
        case BLOCK_TASK_CHECKED: baseColor = COL_TEXT_QUOTE; break;
        case BLOCK_HR: topPad = HR_TOP_PAD; break;
        case BLOCK_IMAGE: topPad = 8; break;
        default: break;
    }
    baseFontSize = theme_scaled_font_size(baseFontSize);

    Clay_ElementDeclaration decl = {0};
    decl.layout.layoutDirection = CLAY_TOP_TO_BOTTOM;
    decl.layout.sizing.width = CLAY_SIZING_FIXED(ctx->contentWidth);
    decl.layout.sizing.height = CLAY_SIZING_FIT(0);
    decl.layout.padding.top = (uint16_t)topPad;
    decl.layout.padding.bottom = BLOCK_GAP;

    if (b->type == BLOCK_QUOTE) {
        decl.border.color = COL_QUOTE_BORDER;
        decl.border.width.left = 4;
        decl.layout.padding.left = QUOTE_INDENT;
    }
    if (b->type == BLOCK_CODE) {
        decl.backgroundColor = COL_CODEBLOCK_BG;
        decl.cornerRadius = CLAY_CORNER_RADIUS(6);
        decl.layout.padding.left = 12;
        decl.layout.padding.right = 12;
        decl.layout.padding.top = (uint16_t)(topPad + 8);
        decl.layout.padding.bottom = (uint16_t)(BLOCK_GAP + 8);
    }

    CLAY(CLAY_IDI("Block", blockIndex), decl) {
        if (b->type == BLOCK_CODE) {
            emit_code_block(ctx, blockIndex, b->text.data, b->text.len);
            if (b->lang.len > 0) {
                Clay_String langText = { .isStaticallyAllocated = false, .length = b->lang.len, .chars = b->lang.data };
                CLAY_AUTO_ID({
                    .layout = { .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) }, .childAlignment = { .x = CLAY_ALIGN_X_RIGHT } },
                }) {
                    CLAY_TEXT(langText, CLAY_TEXT_CONFIG({ .fontId = FONT_MONO_REGULAR, .fontSize = FS_CODE_LANG, .textColor = COL_MARKER }));
                }
            }
        } else if (b->type == BLOCK_HR) {
            CLAY_AUTO_ID({
                .layout = { .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(HR_THICKNESS) } },
                .backgroundColor = COL_HR,
            }) {}
        } else if (b->type == BLOCK_BULLET || b->type == BLOCK_NUMBERED
                   || b->type == BLOCK_TASK_UNCHECKED || b->type == BLOCK_TASK_CHECKED) {
            bool isTask = (b->type == BLOCK_TASK_UNCHECKED || b->type == BLOCK_TASK_CHECKED);

            InlineRunList runs;
            inline_parse(b->text.data, b->text.len, &runs);
            RenderRun *rr; int rc;
            expand_runs(&runs, ctx->cursorBlock, ctx->cursorOffset, blockIndex, &rr, &rc);
            inline_runs_free(&runs);

            CLAY_AUTO_ID({
                .layout = { .layoutDirection = CLAY_LEFT_TO_RIGHT, .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) } },
            }) {
                Clay_ElementId markerId = CLAY_IDI("Marker", blockIndex);
                bool markerHovered = isTask && Clay_PointerOver(markerId);
                CLAY(markerId, {
                    .layout = { .sizing = { CLAY_SIZING_FIXED(LIST_INDENT), CLAY_SIZING_FIT(0) } },
                }) {
                    Clay_String markerText;
                    if (b->type == BLOCK_BULLET) {
                        markerText = CLAY_STRING("\xE2\x80\xA2");
                    } else if (b->type == BLOCK_TASK_UNCHECKED) {
                        markerText = CLAY_STRING("\xE2\x98\x90");
                    } else if (b->type == BLOCK_TASK_CHECKED) {
                        markerText = CLAY_STRING("\xE2\x98\x91");
                    } else {
                        char *buf = scratch_alloc(16);
                        int num = compute_list_number(doc, blockIndex);
                        int n = snprintf(buf, 16, "%d.", num);
                        markerText = (Clay_String){ .isStaticallyAllocated = false, .length = n, .chars = buf };
                    }
                    CLAY_TEXT(markerText, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = (uint16_t)baseFontSize,
                                                               .textColor = markerHovered ? COL_LINK : g_theme.text }));
                }
                if (markerHovered && ctx->mouseLeftPressed && ctx->toggledTaskBlock) {
                    *ctx->toggledTaskBlock = blockIndex;
                }
                CLAY_AUTO_ID({
                    .layout = { .layoutDirection = CLAY_TOP_TO_BOTTOM, .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) } },
                }) {
                    emit_wrapped_lines(ctx, blockIndex, b->text.data, rr, rc, ctx->contentWidth - LIST_INDENT,
                                        baseFontSize, boldBase, baseColor, b->type == BLOCK_TASK_CHECKED, CLAY_ALIGN_X_LEFT);
                }
            }
            free(rr);
        } else if (b->type == BLOCK_IMAGE) {
            char resolvedPath[1024];
            SDL_Texture *texture = NULL;
            int naturalW = 0, naturalH = 0;
            bool haveImage = image_resolve_path(ctx->docFilePath, b->text.data, resolvedPath, (int)sizeof resolvedPath)
                           && image_cache_get(ctx->imageCache, ctx->renderer, resolvedPath, &texture, &naturalW, &naturalH);

            if (haveImage) {
                float displayW = (float)naturalW, displayH = (float)naturalH;
                float scale = 1.0f;
                if (displayW > ctx->contentWidth) scale = ctx->contentWidth / displayW;
                if (displayH * scale > IMAGE_MAX_HEIGHT) scale = IMAGE_MAX_HEIGHT / displayH;
                displayW *= scale;
                displayH *= scale;
                CLAY_AUTO_ID({
                    .layout = { .sizing = { CLAY_SIZING_FIXED(displayW), CLAY_SIZING_FIXED(displayH) } },
                    .image = { .imageData = texture },
                }) {}
            } else {
                /* Broken/missing/unresolved path -- a bordered placeholder showing alt text (or
                   the raw url if there's no alt), so a bad reference doesn't just vanish. */
                const char *fallbackText = b->alt.len > 0 ? b->alt.data : b->text.data;
                int fallbackLen = b->alt.len > 0 ? b->alt.len : b->text.len;
                CLAY_AUTO_ID({
                    .layout = {
                        .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(IMAGE_FALLBACK_HEIGHT) },
                        .padding = CLAY_PADDING_ALL(10),
                        .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
                    },
                    .backgroundColor = COL_CODEBLOCK_BG,
                    .cornerRadius = CLAY_CORNER_RADIUS(6),
                    .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
                }) {
                    if (fallbackLen > 0) {
                        Clay_String s = { .isStaticallyAllocated = false, .length = fallbackLen, .chars = fallbackText };
                        CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = FS_MODAL, .textColor = COL_MARKER, .wrapMode = CLAY_TEXT_WRAP_WORDS }));
                    }
                }
            }

            /* Caption: the raw url, small/muted, deliberately NOT run through inline_parse (a url
               is literal reference text, not prose -- an underscore or asterisk in it shouldn't
               trigger markdown styling). Registered into the layout cache exactly like any other
               line via emit_wrapped_lines, so it's fully cursor-navigable/selectable/editable
               through the existing, unmodified hit-test machinery. */
            RenderRun captionRun = { RK_PLAIN, 0, b->text.len };
            emit_wrapped_lines(ctx, blockIndex, b->text.data, &captionRun, 1, ctx->contentWidth,
                                theme_scaled_font_size(FS_CODE_LANG), false, COL_MARKER, false, CLAY_ALIGN_X_LEFT);
        } else {
            InlineRunList runs;
            inline_parse(b->text.data, b->text.len, &runs);
            RenderRun *rr; int rc;
            expand_runs(&runs, ctx->cursorBlock, ctx->cursorOffset, blockIndex, &rr, &rc);
            inline_runs_free(&runs);

            float avail = ctx->contentWidth - (b->type == BLOCK_QUOTE ? QUOTE_INDENT : 0);
            emit_wrapped_lines(ctx, blockIndex, b->text.data, rr, rc, avail, baseFontSize, boldBase, baseColor, false, CLAY_ALIGN_X_LEFT);
            free(rr);
        }
    }
}

/* Read-only preview of the document as it would actually be saved (MODE_SOURCE_VIEW, toggled
   with Ctrl+M) -- plain monospace text, one Clay text element per line (Clay wraps within an
   element by width, not by embedded '\n', so each raw line needs its own element; word-wraps
   long lines within the content width, same as everything else). */
static void emit_source_view(EditorState *ed, float contentWidth) {
    StrBuf tmp;
    sb_init(&tmp);
    document_serialize(&ed->doc, &tmp);

    /* Clay_String only stores a pointer -- copy into the frame-lifetime scratch buffer before
       `tmp` gets freed, not the other way around. */
    char *src = scratch_alloc(tmp.len);
    memcpy(src, tmp.data, (size_t)tmp.len);
    int srcLen = tmp.len;
    sb_free(&tmp);

    int fontSize = theme_scaled_font_size(FS_CODE);
    float lineHeight = (float)fontSize * LINE_HEIGHT_MULT;
    int pos = 0;
    while (pos < srcLen) {
        int lineStart = pos;
        while (pos < srcLen && src[pos] != '\n') pos++;
        int lineEnd = pos;
        if (pos < srcLen) pos++; /* consume the '\n' */

        Clay_String txt = { .isStaticallyAllocated = false, .length = lineEnd - lineStart, .chars = src + lineStart };
        CLAY_AUTO_ID({
            .layout = { .sizing = { CLAY_SIZING_FIXED(contentWidth), CLAY_SIZING_FIT(lineHeight) } },
        }) {
            CLAY_TEXT(txt, CLAY_TEXT_CONFIG({ .fontId = FONT_MONO_REGULAR, .fontSize = (uint16_t)fontSize,
                                                .textColor = g_theme.text, .wrapMode = CLAY_TEXT_WRAP_WORDS }));
        }
    }
}

static void modal_text_line(const char *text) {
    Clay_String s = { .isStaticallyAllocated = true, .length = (int)strlen(text), .chars = text };
    CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = FS_MODAL, .textColor = g_theme.text, .wrapMode = CLAY_TEXT_WRAP_NONE }));
}

/* A modal's title line (bold, otherwise styled like modal_text_line). */
static void modal_text_line_bold(const char *text) {
    Clay_String s = { .isStaticallyAllocated = true, .length = (int)strlen(text), .chars = text };
    CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_BOLD, .fontSize = FS_MODAL, .textColor = g_theme.text, .wrapMode = CLAY_TEXT_WRAP_NONE }));
}

/* A clickable modal button: highlights on hover, and sets *action to `thisAction` on click. */
static void modal_button(Clay_ElementId id, const char *label, ModalAction *action, ModalAction thisAction, bool mouseLeftPressed) {
    bool hovered = Clay_PointerOver(id);
    CLAY(id, {
        .layout = {
            .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(36) },
            .padding = { 12, 12, 0, 0 },
            .childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
        },
        .backgroundColor = hovered ? COL_BUTTON_HOVER : g_theme.bg,
        .cornerRadius = CLAY_CORNER_RADIUS(4),
        .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
    }) {
        Clay_String s = { .isStaticallyAllocated = true, .length = (int)strlen(label), .chars = label };
        CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = FS_MODAL, .textColor = g_theme.text }));
    }
    if (hovered && mouseLeftPressed) *action = thisAction;
}

/* The open-file text field: a bordered box showing the typed path with a trailing cursor mark. */
static void open_path_field(const OpenPromptState *openPrompt) {
    CLAY(CLAY_ID("OpenPathField"), {
        .layout = {
            .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(36) },
            .padding = { 12, 12, 0, 0 },
            .childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
        },
        .backgroundColor = g_theme.bg,
        .cornerRadius = CLAY_CORNER_RADIUS(4),
        .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
    }) {
        char tmp[sizeof openPrompt->path + 8];
        int n = snprintf(tmp, sizeof tmp, "%.*s|", openPrompt->len, openPrompt->path);
        if (n > (int)sizeof tmp) n = (int)sizeof tmp;
        char *scratchBuf = scratch_alloc(n);
        memcpy(scratchBuf, tmp, (size_t)n);
        Clay_String s = { .isStaticallyAllocated = false, .length = n, .chars = scratchBuf };
        CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_MONO_REGULAR, .fontSize = FS_MODAL, .textColor = g_theme.text, .wrapMode = CLAY_TEXT_WRAP_NONE }));
    }
}

/* Draws the quit-confirm, shortcuts-help, or open-file modal (whichever `mode` calls for) as a
   floating, dimmed overlay on top of everything else, and returns the action the user's click
   resolved to this frame (MODAL_ACTION_NONE if none). No-op for any other mode (MODE_EDITING
   has nothing to overlay; MODE_SOURCE_VIEW is a full alternate view, not a modal). */
static ModalAction emit_modal(AppMode mode, float winW, float winH, const OpenPromptState *openPrompt, bool confirmForOpen, bool mouseLeftPressed) {
    ModalAction action = MODAL_ACTION_NONE;
    if (mode != MODE_QUIT_CONFIRM && mode != MODE_SHORTCUTS_HELP && mode != MODE_OPEN_PROMPT) return action;

    CLAY(CLAY_ID("ModalScrim"), {
        .layout = {
            .sizing = { CLAY_SIZING_FIXED(winW), CLAY_SIZING_FIXED(winH) },
            .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
        },
        .backgroundColor = COL_MODAL_SCRIM,
        .floating = { .attachTo = CLAY_ATTACH_TO_ROOT, .zIndex = 10 },
    }) {
        if (Clay_PointerOver(CLAY_ID("ModalScrim")) && !Clay_PointerOver(CLAY_ID("ModalBox")) && mouseLeftPressed) {
            action = (mode == MODE_SHORTCUTS_HELP) ? MODAL_ACTION_CLOSE_HELP : MODAL_ACTION_CANCEL;
        }

        CLAY(CLAY_ID("ModalBox"), {
            .layout = {
                .layoutDirection = CLAY_TOP_TO_BOTTOM,
                .sizing = { CLAY_SIZING_FIXED(mode == MODE_SHORTCUTS_HELP ? 420 : 380), CLAY_SIZING_FIT(0) },
                .padding = CLAY_PADDING_ALL(24),
                .childGap = 12,
            },
            .backgroundColor = g_theme.bg,
            .cornerRadius = CLAY_CORNER_RADIUS(8),
            .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
        }) {
            if (mode == MODE_QUIT_CONFIRM) {
                modal_text_line_bold(lang_get(confirmForOpen ? STR_QUIT_TITLE_OPEN : STR_QUIT_TITLE));
                modal_button(CLAY_ID("BtnSaveQuit"), lang_get(confirmForOpen ? STR_QUIT_SAVE_OPEN : STR_QUIT_SAVE), &action, MODAL_ACTION_SAVE_QUIT, mouseLeftPressed);
                modal_button(CLAY_ID("BtnDiscardQuit"), lang_get(confirmForOpen ? STR_QUIT_DISCARD_OPEN : STR_QUIT_DISCARD), &action, MODAL_ACTION_DISCARD_QUIT, mouseLeftPressed);
                modal_button(CLAY_ID("BtnCancel"), lang_get(STR_QUIT_CANCEL), &action, MODAL_ACTION_CANCEL, mouseLeftPressed);
            } else if (mode == MODE_OPEN_PROMPT) {
                modal_text_line_bold(lang_get(STR_OPEN_TITLE));
                open_path_field(openPrompt);
                if (openPrompt->showError) modal_text_line(lang_get(STR_OPEN_ERROR));
                modal_button(CLAY_ID("BtnOpenConfirm"), lang_get(STR_OPEN_CONFIRM), &action, MODAL_ACTION_OPEN_CONFIRM, mouseLeftPressed);
                modal_button(CLAY_ID("BtnCancel"), lang_get(STR_QUIT_CANCEL), &action, MODAL_ACTION_CANCEL, mouseLeftPressed);
            } else {
                modal_text_line_bold(lang_get(STR_HELP_TITLE));
                modal_text_line(lang_get(STR_HELP_ARROWS));
                modal_text_line(lang_get(STR_HELP_SHIFT_ARROWS));
                modal_text_line(lang_get(STR_HELP_MOUSE_DRAG));
                modal_text_line(lang_get(STR_HELP_SAVE));
                modal_text_line(lang_get(STR_HELP_OPEN));
                modal_text_line(lang_get(STR_HELP_COPY_PASTE));
                modal_text_line(lang_get(STR_HELP_UNDO_REDO));
                modal_text_line(lang_get(STR_HELP_QUIT));
                modal_text_line(lang_get(STR_HELP_THIS_HELP));
                modal_text_line(lang_get(STR_HELP_ENTER));
                modal_text_line(lang_get(STR_HELP_BACKSPACE_DELETE));
                modal_text_line(lang_get(STR_HELP_HOME_END));
                modal_text_line(lang_get(STR_HELP_SOURCE_VIEW));
                modal_text_line(lang_get(STR_HELP_ZOOM));
                modal_text_line(lang_get(STR_HELP_QUICK_INSERT));
                modal_button(CLAY_ID("BtnCloseHelp"), lang_get(STR_HELP_CLOSE), &action, MODAL_ACTION_CLOSE_HELP, mouseLeftPressed);
            }
        }
    }
    return action;
}

/* One row shared by the "@" quick-insert popup and the left-margin "Turn Into" menu: an icon
   glyph + label, highlighted on hover or (once Phase 2 wires keyboard nav in) on
   `keyboardSelected`. Sets *outClickedType to the row's BlockType on click. */
static void block_type_menu_row(Clay_ElementId id, const BlockTypeMenuItem *item, bool keyboardSelected,
                                 bool mouseLeftPressed, int *outClickedType) {
    bool hovered = Clay_PointerOver(id);
    CLAY(id, {
        .layout = {
            .layoutDirection = CLAY_LEFT_TO_RIGHT,
            .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(BLOCK_TYPE_MENU_ROW_HEIGHT) },
            .padding = { 10, 10, 0, 0 },
            .childGap = 10,
            .childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
        },
        .backgroundColor = (hovered || keyboardSelected) ? COL_BUTTON_HOVER : g_theme.bg,
        .cornerRadius = CLAY_CORNER_RADIUS(4),
    }) {
        Clay_String icon = { .isStaticallyAllocated = true, .length = (int)strlen(item->icon), .chars = item->icon };
        CLAY_AUTO_ID({ .layout = { .sizing = { CLAY_SIZING_FIXED(28), CLAY_SIZING_FIT(0) } } }) {
            CLAY_TEXT(icon, CLAY_TEXT_CONFIG({ .fontId = FONT_MONO_REGULAR, .fontSize = FS_MODAL, .textColor = COL_MARKER }));
        }
        const char *label = lang_get(item->label);
        Clay_String s = { .isStaticallyAllocated = true, .length = (int)strlen(label), .chars = label };
        CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = FS_MODAL, .textColor = g_theme.text }));
    }
    if (hovered && mouseLeftPressed && outClickedType) *outClickedType = (int)item->type;
}

typedef struct {
    Clay_FloatingAttachPoints attachPoints;
    Clay_Vector2 offset;
} BlockTypeMenuPlacement;

/* Decides whether a block-type popup anchored below `anchorId` should instead open upward --
   there isn't `rowCount` rows' worth of room before the window bottom. Reads `anchorId`'s
   bounding box as of the last completed layout (the same previous-frame-data idiom
   Clay_PointerOver already relies on elsewhere in this file -- these elements don't move except
   in response to user action, so a one-frame-stale position is a non-issue in practice). */
static BlockTypeMenuPlacement block_type_menu_placement(Clay_ElementId anchorId, int rowCount, float winH) {
    float menuHeight = (float)(rowCount > 0 ? rowCount : 1) * BLOCK_TYPE_MENU_ROW_HEIGHT + 8.0f;
    Clay_ElementData d = Clay_GetElementData(anchorId);
    bool openUpward = d.found && (d.boundingBox.y + d.boundingBox.height + BLOCK_TYPE_MENU_GAP + menuHeight > winH);
    if (openUpward) {
        return (BlockTypeMenuPlacement){
            .attachPoints = { .element = CLAY_ATTACH_POINT_LEFT_BOTTOM, .parent = CLAY_ATTACH_POINT_LEFT_TOP },
            .offset = { 0, -BLOCK_TYPE_MENU_GAP },
        };
    }
    return (BlockTypeMenuPlacement){
        .attachPoints = { .element = CLAY_ATTACH_POINT_LEFT_TOP, .parent = CLAY_ATTACH_POINT_LEFT_BOTTOM },
        .offset = { 0, BLOCK_TYPE_MENU_GAP },
    };
}

/* The left-margin "Turn Into" icon (always anchored to ed->cursorBlock) and, when `ti->open`,
   its dropdown -- lets the user change an *existing* block's type while preserving its content.
   Never offers BLOCK_HR (see BlockTypeMenuItem.offerInTurnInto). No-op outside MODE_EDITING
   (guarded by the caller, since ed->cursorBlock/CLAY_IDI("Block", ...) only exist there). */
static void emit_turn_into(RenderCtx *ctx, EditorState *ed, const TurnIntoMenuState *ti, BlockTypeMenuClickResult *out) {
    Clay_ElementId iconId = CLAY_ID("TurnIntoIcon");
    Clay_ElementId blockId = CLAY_IDI("Block", ed->cursorBlock);
    bool iconHovered = Clay_PointerOver(iconId);

    CLAY(iconId, {
        .layout = {
            .sizing = { CLAY_SIZING_FIXED(TURN_INTO_ICON_SIZE), CLAY_SIZING_FIXED(TURN_INTO_ICON_SIZE) },
            .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
        },
        .backgroundColor = iconHovered ? COL_BUTTON_HOVER : g_theme.bg,
        .cornerRadius = CLAY_CORNER_RADIUS(4),
        .floating = {
            .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID,
            .parentId = blockId.id,
            .attachPoints = { .element = CLAY_ATTACH_POINT_RIGHT_TOP, .parent = CLAY_ATTACH_POINT_LEFT_TOP },
            .offset = { -TURN_INTO_ICON_GAP, 2 },
            .clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
        },
    }) {
        CLAY_TEXT(CLAY_STRING("..."), CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_BOLD, .fontSize = FS_MODAL, .textColor = COL_MARKER }));
    }
    if (iconHovered && ctx->mouseLeftPressed) out->turnIntoIconClicked = true;

    if (!ti->open) return;

    Clay_ElementId menuId = CLAY_ID("TurnIntoMenuBox");
    bool overMenu = Clay_PointerOver(menuId);
    if (!iconHovered && !overMenu && ctx->mouseLeftPressed) out->turnIntoClickedOutside = true;

    int turnIntoRowCount = 0;
    for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
        if (BLOCK_TYPE_MENU_ITEMS[i].offerInTurnInto) turnIntoRowCount++;
    }
    BlockTypeMenuPlacement placement = block_type_menu_placement(iconId, turnIntoRowCount, ctx->winH);

    CLAY(menuId, {
        .layout = {
            .layoutDirection = CLAY_TOP_TO_BOTTOM,
            .sizing = { CLAY_SIZING_FIXED(TURN_INTO_MENU_WIDTH), CLAY_SIZING_FIT(0) },
            .padding = CLAY_PADDING_ALL(4),
        },
        .backgroundColor = g_theme.bg,
        .cornerRadius = CLAY_CORNER_RADIUS(6),
        .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
        .floating = {
            .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID,
            .parentId = iconId.id,
            .attachPoints = placement.attachPoints,
            .offset = placement.offset,
            .clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
            .zIndex = 5,
        },
    }) {
        for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
            const BlockTypeMenuItem *item = &BLOCK_TYPE_MENU_ITEMS[i];
            if (!item->offerInTurnInto) continue;
            block_type_menu_row(CLAY_IDI("TurnIntoRow", i), item, false, ctx->mouseLeftPressed, &out->turnIntoClickedType);
        }
    }
}

/* The "@" quick-insert popup: floats below ed->cursorBlock (guaranteed a BLOCK_PARAGRAPH by
   block_type_menu_quick_insert_trigger_active), filtered live against the block's own "@word"
   text (everything after the "@"). Unlike Turn Into, every item including BLOCK_HR is offered --
   the target block is always empty, so there's nothing to lose by clearing it into a divider. */
static void emit_quick_insert_menu(RenderCtx *ctx, EditorState *ed, const QuickInsertMenuState *qi, BlockTypeMenuClickResult *out) {
    Block *b = &ed->doc.blocks[ed->cursorBlock];
    const char *filter = b->text.data + 1;
    int filterLen = b->text.len - 1;

    int matchCount = 0;
    for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
        if (block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], filter, filterLen)) matchCount++;
    }

    Clay_ElementId blockId = CLAY_IDI("Block", ed->cursorBlock);
    BlockTypeMenuPlacement placement = block_type_menu_placement(blockId, matchCount, ctx->winH);

    CLAY(CLAY_ID("QuickInsertMenu"), {
        .layout = {
            .layoutDirection = CLAY_TOP_TO_BOTTOM,
            .sizing = { CLAY_SIZING_FIXED(QUICK_INSERT_MENU_WIDTH), CLAY_SIZING_FIT(0) },
            .padding = CLAY_PADDING_ALL(4),
        },
        .backgroundColor = g_theme.bg,
        .cornerRadius = CLAY_CORNER_RADIUS(6),
        .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
        .floating = {
            .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID,
            .parentId = blockId.id,
            .attachPoints = placement.attachPoints,
            .offset = placement.offset,
            .clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
            .zIndex = 5,
        },
    }) {
        int filteredIdx = 0;
        for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
            const BlockTypeMenuItem *item = &BLOCK_TYPE_MENU_ITEMS[i];
            if (!block_type_menu_item_matches(item, filter, filterLen)) continue;
            block_type_menu_row(CLAY_IDI("QuickInsertRow", filteredIdx), item, filteredIdx == qi->selectedIndex,
                                 ctx->mouseLeftPressed, &out->quickInsertClickedType);
            filteredIdx++;
        }
        if (filteredIdx == 0) {
            modal_text_line(lang_get(STR_QUICK_INSERT_EMPTY));
        }
    }
}

Clay_Dimensions hush_measure_text(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData) {
    SDL2_Font *fonts = (SDL2_Font *)userData;
    TTF_Font *font = fonts[config->fontId].font;
    if (!font) return (Clay_Dimensions){0, 0}; /* insurance only -- every slot is load-or-exit at startup */
    TTF_SetFontSize(font, config->fontSize);

    char buf[1024];
    int n = text.length;
    if (n >= (int)sizeof(buf)) n = sizeof(buf) - 1;
    if (n > 0) memcpy(buf, text.chars, (size_t)n);
    buf[n] = '\0';

    int w = 0, h = 0;
    TTF_SizeUTF8(font, buf, &w, &h);
    return (Clay_Dimensions){ (float)w, h > 0 ? (float)h : (float)config->fontSize };
}

ModalAction render_frame(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts, SDL_Renderer *renderer,
                          bool mouseLeftPressed, float winW, float winH, float deltaTime, float caretBlinkT, AppMode mode,
                          int *outToggledTaskBlock, const OpenPromptState *openPrompt, bool confirmForOpen,
                          const TurnIntoMenuState *turnIntoMenu, const QuickInsertMenuState *quickInsertMenu,
                          BlockTypeMenuClickResult *outMenuClick, ImageCache *imageCache) {
    g_scratchPos = 0;
    g_strikeMarkCount = 0;
    layout_cache_reset(cache);
    *outMenuClick = (BlockTypeMenuClickResult){ .quickInsertClickedType = -1, .turnIntoClickedType = -1 };

    float contentWidth = winW - 2.0f * CONTENT_SIDE_PADDING;
    if (contentWidth > CONTENT_MAX_WIDTH) contentWidth = CONTENT_MAX_WIDTH;
    if (contentWidth < 100.0f) contentWidth = 100.0f;

    int toggledTaskBlock = -1;
    RenderCtx ctx = { fonts, cache, contentWidth, ed->cursorBlock, ed->cursorOffset, &toggledTaskBlock, mouseLeftPressed, winH,
                       renderer, imageCache, ed->filePath };

    Clay_BeginLayout();

    CLAY(CLAY_ID("Root"), {
        .layout = { .sizing = { CLAY_SIZING_FIXED(winW), CLAY_SIZING_FIXED(winH) }, .layoutDirection = CLAY_TOP_TO_BOTTOM },
        .backgroundColor = g_theme.bg,
    }) {
        CLAY(CLAY_ID("ScrollArea"), {
            .layout = {
                .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_GROW(0) },
                .childAlignment = { .x = CLAY_ALIGN_X_CENTER },
                .padding = { 0, 0, 28, 48 },
            },
            .clip = { .vertical = true, .childOffset = Clay_GetScrollOffset() },
        }) {
            CLAY(CLAY_ID("ContentColumn"), {
                .layout = { .layoutDirection = CLAY_TOP_TO_BOTTOM, .sizing = { CLAY_SIZING_FIXED(contentWidth), CLAY_SIZING_FIT(0) } },
            }) {
                if (mode == MODE_SOURCE_VIEW) {
                    emit_source_view(ed, contentWidth);
                } else {
                    for (int i = 0; i < ed->doc.count; i++) {
                        if (ed->doc.blocks[i].type == BLOCK_TABLE_HEADER_CELL) {
                            int cols = ed->doc.blocks[i].tableCols;
                            int extent = i;
                            while (extent < ed->doc.count && block_type_is_table_cell(ed->doc.blocks[extent].type)
                                   && ed->doc.blocks[extent].tableCols == cols
                                   /* a second BLOCK_TABLE_HEADER_CELL at tableCol 0 can only be another
                                      table's own header starting right after this one with no separating
                                      block -- the whole header row shares that type, so this can't fire
                                      on this table's own header cells beyond the very first (extent == i). */
                                   && !(extent != i && ed->doc.blocks[extent].type == BLOCK_TABLE_HEADER_CELL
                                        && ed->doc.blocks[extent].tableCol == 0)) {
                                extent++;
                            }
                            int rows = (extent - i) / cols;
                            emit_table(&ctx, &ed->doc, i, rows, cols);
                            i = extent - 1; /* the enclosing for's i++ advances past the last cell handled */
                        } else {
                            emit_block(&ctx, &ed->doc, i);
                        }
                    }
                }
            }
        }

        CLAY(CLAY_ID("StatusBar"), {
            .layout = {
                .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(STATUSBAR_HEIGHT) },
                .layoutDirection = CLAY_LEFT_TO_RIGHT,
                .childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
                .padding = { STATUSBAR_PADDING, STATUSBAR_PADDING, 0, 0 },
            },
            .backgroundColor = COL_STATUSBAR_BG,
        }) {
            char tmp[512];
            int n;
            if (mode == MODE_SOURCE_VIEW) {
                const char *hint = lang_get(STR_SOURCE_HINT);
                n = snprintf(tmp, sizeof tmp, "%s", hint);
            } else {
                DocStats stats;
                document_compute_stats(&ed->doc, &stats);
                const char *name = editor_display_name(ed);
                n = snprintf(tmp, sizeof tmp, "%s%s  \xC2\xB7  %.1f KB  \xC2\xB7  %d %s",
                             ed->dirty ? "*" : "", name, stats.byteSize / 1024.0, stats.lineCount,
                             lang_get(stats.lineCount == 1 ? STR_STATUSBAR_LINE : STR_STATUSBAR_LINES));
            }
            if (n > (int)sizeof tmp) n = (int)sizeof tmp;
            char *scratchBuf = scratch_alloc(n);
            memcpy(scratchBuf, tmp, (size_t)n);
            Clay_String statusText = { .isStaticallyAllocated = false, .length = n, .chars = scratchBuf };
            CLAY_TEXT(statusText, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = FS_STATUSBAR,
                                                       .textColor = COL_STATUSBAR_TEXT, .wrapMode = CLAY_TEXT_WRAP_NONE }));
        }
    }

    if (mode == MODE_EDITING) {
        if (quickInsertMenu->open) {
            emit_quick_insert_menu(&ctx, ed, quickInsertMenu, outMenuClick);
        } else if (!block_type_is_table_cell(ed->doc.blocks[ed->cursorBlock].type)) {
            /* Turn-Into changes one block's type in place, desyncing it from the table's
               rows*cols run and silently truncating/orphaning the rest -- suppress it entirely
               while the cursor is in a table cell (there is no dedicated "leave the table"
               conversion path the way there is for every other type). */
            emit_turn_into(&ctx, ed, turnIntoMenu, outMenuClick);
        }
    }

    ModalAction modalAction = emit_modal(mode, winW, winH, openPrompt, confirmForOpen, mouseLeftPressed);

    Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime);
    Clay_SDL2_Render(renderer, cmds, fonts);

    for (int i = 0; i < g_strikeMarkCount; i++) {
        Clay_ElementData sd = Clay_GetElementData(CLAY_IDI("Seg", g_strikeMarks[i].segIdx));
        if (!sd.found) continue;
        Clay_Color c = g_strikeMarks[i].color;
        SDL_SetRenderDrawColor(renderer, (Uint8)c.r, (Uint8)c.g, (Uint8)c.b, (Uint8)c.a);
        int midY = (int)(sd.boundingBox.y + sd.boundingBox.height * 0.5f);
        SDL_Rect strikeRect = { (int)sd.boundingBox.x, midY, (int)sd.boundingBox.width, 1 };
        SDL_RenderFillRect(renderer, &strikeRect);
    }

    if (mode == MODE_EDITING && editor_has_selection(ed)) {
        int sb, so, eb, eo;
        editor_selection_range(ed, &sb, &so, &eb, &eo);
        SDL_SetRenderDrawColor(renderer, (Uint8)COL_SELECTION.r, (Uint8)COL_SELECTION.g, (Uint8)COL_SELECTION.b, (Uint8)COL_SELECTION.a);
        for (int i = 0; i < cache->lineCount; i++) {
            CachedLine *line = &cache->lines[i];
            if (line->blockIndex < sb || line->blockIndex > eb) continue;
            int rangeStart = (line->blockIndex == sb) ? so : 0;
            int rangeEnd = (line->blockIndex == eb) ? eo : ed->doc.blocks[line->blockIndex].text.len;
            float x, y, w, h;
            if (hittest_line_range_box(cache, &ed->doc, fonts, i, rangeStart, rangeEnd, &x, &y, &w, &h)) {
                SDL_Rect selRect = { (int)x, (int)y, (int)w, (int)h };
                SDL_RenderFillRect(renderer, &selRect);
            }
        }
    }

    if (mode == MODE_EDITING) {
        float cx, cy, ch;
        if (hittest_caret(cache, &ed->doc, fonts, ed->cursorBlock, ed->cursorOffset, &cx, &cy, &ch)) {
            if (ed->cursorBlock != s_lastScrollCursorBlock || ed->cursorOffset != s_lastScrollCursorOffset) {
                s_lastScrollCursorBlock = ed->cursorBlock;
                s_lastScrollCursorOffset = ed->cursorOffset;
                Clay_ElementData viewport = Clay_GetElementData(CLAY_ID("ScrollArea"));
                Clay_ScrollContainerData scroll = Clay_GetScrollContainerData(CLAY_ID("ScrollArea"));
                if (viewport.found && scroll.found) {
                    float viewTop = viewport.boundingBox.y;
                    float viewBottom = viewTop + viewport.boundingBox.height;
                    if (cy < viewTop) {
                        scroll.scrollPosition->y += viewTop - cy;
                    } else if (cy + ch > viewBottom) {
                        scroll.scrollPosition->y -= (cy + ch) - viewBottom;
                    }
                }
            }
            if (fmodf(caretBlinkT, 1.0f) < 0.5f) {
                SDL_SetRenderDrawColor(renderer, (Uint8)g_theme.cursor.r, (Uint8)g_theme.cursor.g, (Uint8)g_theme.cursor.b, (Uint8)g_theme.cursor.a);
                SDL_Rect caretRect = { (int)cx, (int)cy, 2, (int)ch };
                SDL_RenderFillRect(renderer, &caretRect);
            }
        }
    }

    if (outToggledTaskBlock) *outToggledTaskBlock = toggledTaskBlock;
    return modalAction;
}
