foxygit / Hush Log in
commit 9e3e416d9ea178484ebb27756602708ebe83b66b
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Wed Aug 26 09:32:56 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Wed Aug 26 09:32:56 2026 +0200

    Add real image rendering for "![alt](url)"

    Whole-line image markdown now becomes a real BLOCK_IMAGE (SDL2_image was
    already a linked dependency, just never exercised) instead of falling
    through as plain text: the picture renders at a clamped, aspect-ratio-
    preserving size with a bordered alt-text fallback for missing/broken
    references, and the raw url shows below as a small editable caption
    that reuses the existing text/hit-test machinery. Reachable via
    Turn-Into (preserves existing text as the url) and quick-insert
    "@image". Adds a process-lifetime image cache keyed by resolved path
    with mtime-based invalidation, and fixes a real perf bug in the
    vendored SDL2 renderer that was recreating a GPU texture every frame
    for any image.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 CMakeLists.txt              |   2 +
 assets/lang/en.lang         |   1 +
 assets/lang/sv.lang         |   1 +
 src/block_type_menu.c       |   1 +
 src/block_type_menu.h       |   2 +-
 src/document.c              |   5 ++
 src/document.h              |   2 +
 src/editor.c                |  18 +++++++
 src/image_cache.c           | 128 ++++++++++++++++++++++++++++++++++++++++++++
 src/image_cache.h           |  27 ++++++++++
 src/lang.c                  |   2 +
 src/lang.h                  |   1 +
 src/main.c                  |   7 ++-
 src/markdown_io.c           |  33 +++++++++++-
 src/render.c                |  58 +++++++++++++++++++-
 src/render.h                |   6 ++-
 src/test_main.c             | 111 ++++++++++++++++++++++++++++++++++++++
 src/theme.h                 |   3 ++
 vendor/clay_renderer_SDL2.c |  16 +++---
 19 files changed, 411 insertions(+), 13 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 19ce2cd..e7f98cc 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -71,6 +71,7 @@ add_executable(hush
     src/paths.c
     src/editor.c
     src/block_type_menu.c
+    src/image_cache.c
     src/render.c
     src/markdown_io.c
     src/main.c
@@ -114,6 +115,7 @@ add_executable(hush_test
     src/paths.c
     src/editor.c
     src/block_type_menu.c
+    src/image_cache.c
     src/render.c
     src/markdown_io.c
     src/test_main.c
diff --git a/assets/lang/en.lang b/assets/lang/en.lang
index 8c9c35b..3702d97 100644
--- a/assets/lang/en.lang
+++ b/assets/lang/en.lang
@@ -64,6 +64,7 @@ block.quote = Quote
 block.code = Code block
 block.task = Task list
 block.hr = Horizontal rule
+block.image = Image

 # Shown in the "@" quick-insert popup when nothing matches the typed filter
 quick_insert.empty = No matches
diff --git a/assets/lang/sv.lang b/assets/lang/sv.lang
index 769549f..00334fa 100644
--- a/assets/lang/sv.lang
+++ b/assets/lang/sv.lang
@@ -61,6 +61,7 @@ block.quote = Citat
 block.code = Kodblock
 block.task = Checklista
 block.hr = Horisontell linje
+block.image = Bild

 # Visas i "@"-snabbmenyn när inget matchar det skrivna filtret
 quick_insert.empty = Inga träffar
diff --git a/src/block_type_menu.c b/src/block_type_menu.c
index 43ee6a2..8c897f2 100644
--- a/src/block_type_menu.c
+++ b/src/block_type_menu.c
@@ -19,6 +19,7 @@ const BlockTypeMenuItem BLOCK_TYPE_MENU_ITEMS[BLOCK_TYPE_MENU_ITEM_COUNT] = {
     { BLOCK_CODE,             STR_BLOCK_CODE,     "```",    "code codeblock fence",   true  },
     { BLOCK_TASK_UNCHECKED,   STR_BLOCK_TASK,     "\xE2\x98\x90", "task todo checkbox checklist", true },
     { BLOCK_HR,               STR_BLOCK_HR,       "\xE2\x80\x94", "hr rule divider horizontal", false },
+    { BLOCK_IMAGE,            STR_BLOCK_IMAGE,    "IMG",    "image picture img photo url", true },
 };

 static bool ci_contains(const char *haystack, const char *needle, int needleLen) {
diff --git a/src/block_type_menu.h b/src/block_type_menu.h
index c120b59..866a25e 100644
--- a/src/block_type_menu.h
+++ b/src/block_type_menu.h
@@ -19,7 +19,7 @@ typedef struct {
                                would silently discard whatever the block held. */
 } BlockTypeMenuItem;

-#define BLOCK_TYPE_MENU_ITEM_COUNT 12
+#define BLOCK_TYPE_MENU_ITEM_COUNT 13
 extern const BlockTypeMenuItem BLOCK_TYPE_MENU_ITEMS[BLOCK_TYPE_MENU_ITEM_COUNT];

 /* Case-insensitive substring match of `filter` against the item's keyword(s). An empty filter
diff --git a/src/document.c b/src/document.c
index 0ccdcd4..580e03a 100644
--- a/src/document.c
+++ b/src/document.c
@@ -21,6 +21,7 @@ void document_free(Document *doc) {
     for (int i = 0; i < doc->count; i++) {
         sb_free(&doc->blocks[i].text);
         sb_free(&doc->blocks[i].lang);
+        sb_free(&doc->blocks[i].alt);
     }
     free(doc->blocks);
     doc->blocks = NULL;
@@ -37,6 +38,7 @@ void document_insert_block(Document *doc, int index, BlockType type, const char
     sb_init(&b->text);
     sb_append(&b->text, text, len);
     sb_init(&b->lang);
+    sb_init(&b->alt);
 }

 void document_clone(Document *dst, const Document *src) {
@@ -49,12 +51,15 @@ void document_clone(Document *dst, const Document *src) {
         sb_append(&dst->blocks[i].text, src->blocks[i].text.data, src->blocks[i].text.len);
         sb_init(&dst->blocks[i].lang);
         sb_append(&dst->blocks[i].lang, src->blocks[i].lang.data, src->blocks[i].lang.len);
+        sb_init(&dst->blocks[i].alt);
+        sb_append(&dst->blocks[i].alt, src->blocks[i].alt.data, src->blocks[i].alt.len);
     }
 }

 void document_remove_block(Document *doc, int index) {
     sb_free(&doc->blocks[index].text);
     sb_free(&doc->blocks[index].lang);
+    sb_free(&doc->blocks[index].alt);
     memmove(&doc->blocks[index], &doc->blocks[index + 1], (size_t)(doc->count - index - 1) * sizeof(Block));
     doc->count--;
 }
diff --git a/src/document.h b/src/document.h
index f8ccfb1..eed90e2 100644
--- a/src/document.h
+++ b/src/document.h
@@ -18,12 +18,14 @@ typedef enum {
     BLOCK_HR,             /* horizontal rule; text/lang are always empty, never edited */
     BLOCK_TASK_UNCHECKED, /* "- [ ] text" */
     BLOCK_TASK_CHECKED,   /* "- [x] text" */
+    BLOCK_IMAGE,          /* "![alt](url)"; text holds the url, alt holds the alt text */
 } BlockType;

 typedef struct {
     BlockType type;
     StrBuf text; /* raw text, without block-level prefix. Code blocks may embed '\n'. */
     StrBuf lang; /* BLOCK_CODE only: the fenced code's info-string (e.g. "c"), or empty. */
+    StrBuf alt;  /* BLOCK_IMAGE only: the image's alt text, or empty. */
 } Block;

 typedef struct {
diff --git a/src/editor.c b/src/editor.c
index 8893765..24784ca 100644
--- a/src/editor.c
+++ b/src/editor.c
@@ -230,6 +230,9 @@ static void backspace_raw(EditorState *ed) {
         if (b->type == BLOCK_CODE) {
             for (int i = 0; i < b->text.len; i++) if (b->text.data[i] == '\n') b->text.data[i] = ' ';
         }
+        if (b->type == BLOCK_IMAGE) {
+            sb_clear(&b->alt);
+        }
         b->type = BLOCK_PARAGRAPH;
         ed->dirty = true;
     } else if (ed->cursorBlock > 0) {
@@ -342,6 +345,18 @@ static void enter_raw(EditorState *ed) {
         return;
     }

+    if (b->type == BLOCK_IMAGE) {
+        /* text is the image's url -- never split/truncate it, regardless of cursor position in
+           the caption. Mirrors the BLOCK_HR-insertion pattern above: always land in a fresh
+           empty paragraph right after it. */
+        document_insert_block(&ed->doc, ed->cursorBlock + 1, BLOCK_PARAGRAPH, "", 0);
+        ed->cursorBlock++;
+        ed->cursorOffset = 0;
+        ed->dirty = true;
+        reset_preferred_x(ed);
+        return;
+    }
+
     int splitAt = ed->cursorOffset;
     int tailLen = b->text.len - splitAt;
     BlockType newType = block_type_is_continuable(b->type) ? b->type : BLOCK_PARAGRAPH;
@@ -489,6 +504,9 @@ static void set_block_type_raw(EditorState *ed, BlockType newType) {
         for (int i = 0; i < b->text.len; i++) if (b->text.data[i] == '\n') b->text.data[i] = ' ';
         sb_clear(&b->lang);
     }
+    if (b->type == BLOCK_IMAGE && newType != BLOCK_IMAGE) {
+        sb_clear(&b->alt);
+    }
     if (newType == BLOCK_HR) {
         /* HR can't hold a cursor, so give it an empty paragraph to land in right after --
            mirrors the existing "---" + space/Enter autoformat paths exactly. */
diff --git a/src/image_cache.c b/src/image_cache.c
new file mode 100644
index 0000000..b4af881
--- /dev/null
+++ b/src/image_cache.c
@@ -0,0 +1,128 @@
+#include "image_cache.h"
+#include <SDL_image.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+#include <sys/stat.h>
+
+bool image_resolve_path(const char *docFilePath, const char *imageRef, char *outBuf, int outBufSize) {
+    if (!imageRef || imageRef[0] == '\0') return false;
+    int n;
+    if (imageRef[0] == '/') {
+        n = snprintf(outBuf, (size_t)outBufSize, "%s", imageRef);
+        return n > 0 && n < outBufSize;
+    }
+    if (!docFilePath) return false;
+    const char *slash = strrchr(docFilePath, '/');
+    if (slash) {
+        int dirLen = (int)(slash - docFilePath) + 1; /* includes the trailing '/' */
+        n = snprintf(outBuf, (size_t)outBufSize, "%.*s%s", dirLen, docFilePath, imageRef);
+    } else {
+        /* docFilePath has no directory component -- it's a bare filename in the CWD. */
+        n = snprintf(outBuf, (size_t)outBufSize, "%s", imageRef);
+    }
+    return n > 0 && n < outBufSize;
+}
+
+typedef struct {
+    char *path;
+    SDL_Texture *texture;
+    int width, height;
+    time_t mtime;
+    bool loaded; /* true once a load has been attempted for the current mtime (success or fail) */
+    bool failed; /* meaningful only when loaded == true */
+} ImageCacheEntry;
+
+struct ImageCache {
+    ImageCacheEntry *items;
+    int count, cap;
+};
+
+static char *dup_str(const char *s) {
+    size_t n = strlen(s);
+    char *copy = malloc(n + 1);
+    memcpy(copy, s, n + 1);
+    return copy;
+}
+
+void image_cache_init(ImageCache **cache) {
+    ImageCache *c = malloc(sizeof(ImageCache));
+    c->items = NULL;
+    c->count = 0;
+    c->cap = 0;
+    *cache = c;
+}
+
+void image_cache_free(ImageCache *cache) {
+    if (!cache) return;
+    for (int i = 0; i < cache->count; i++) {
+        if (cache->items[i].texture) SDL_DestroyTexture(cache->items[i].texture);
+        free(cache->items[i].path);
+    }
+    free(cache->items);
+    free(cache);
+}
+
+static ImageCacheEntry *find_entry(ImageCache *cache, const char *path) {
+    for (int i = 0; i < cache->count; i++) {
+        if (strcmp(cache->items[i].path, path) == 0) return &cache->items[i];
+    }
+    return NULL;
+}
+
+static ImageCacheEntry *add_entry(ImageCache *cache, const char *path) {
+    if (cache->count >= cache->cap) {
+        int newCap = cache->cap > 0 ? cache->cap * 2 : 8;
+        cache->items = realloc(cache->items, (size_t)newCap * sizeof(ImageCacheEntry));
+        cache->cap = newCap;
+    }
+    ImageCacheEntry *e = &cache->items[cache->count++];
+    memset(e, 0, sizeof(*e));
+    e->path = dup_str(path);
+    return e;
+}
+
+static void load_entry(ImageCacheEntry *e, SDL_Renderer *renderer, time_t mtime) {
+    if (e->texture) { SDL_DestroyTexture(e->texture); e->texture = NULL; }
+    e->loaded = true;
+    e->mtime = mtime;
+
+    SDL_Surface *surface = IMG_Load(e->path);
+    if (!surface) { e->failed = true; return; }
+
+    SDL_Texture *tex = SDL_CreateTextureFromSurface(renderer, surface);
+    int w = surface->w, h = surface->h;
+    SDL_FreeSurface(surface);
+    if (!tex) { e->failed = true; return; }
+
+    e->texture = tex;
+    e->width = w;
+    e->height = h;
+    e->failed = false;
+}
+
+bool image_cache_get(ImageCache *cache, SDL_Renderer *renderer, const char *resolvedPath,
+                      SDL_Texture **outTexture, int *outW, int *outH) {
+    struct stat st;
+    if (stat(resolvedPath, &st) != 0) {
+        ImageCacheEntry *e = find_entry(cache, resolvedPath);
+        if (e) {
+            if (e->texture) { SDL_DestroyTexture(e->texture); e->texture = NULL; }
+            e->loaded = false;
+        }
+        return false;
+    }
+
+    ImageCacheEntry *e = find_entry(cache, resolvedPath);
+    if (!e) e = add_entry(cache, resolvedPath);
+
+    if (!e->loaded || e->mtime != st.st_mtime) {
+        load_entry(e, renderer, st.st_mtime);
+    }
+
+    if (e->failed || !e->texture) return false;
+    *outTexture = e->texture;
+    *outW = e->width;
+    *outH = e->height;
+    return true;
+}
diff --git a/src/image_cache.h b/src/image_cache.h
new file mode 100644
index 0000000..0c13023
--- /dev/null
+++ b/src/image_cache.h
@@ -0,0 +1,27 @@
+#ifndef HUSH_IMAGE_CACHE_H
+#define HUSH_IMAGE_CACHE_H
+#include <SDL.h>
+#include <stdbool.h>
+
+/* Pure path resolution -- no SDL/file-I/O, unit-testable. Absolute imageRef (leading '/') passes
+   through unchanged; a relative imageRef is joined against the directory of docFilePath. Returns
+   false (nothing written to outBuf) if imageRef is NULL/empty, or if imageRef is relative and
+   docFilePath is NULL (an unsaved buffer has no directory to resolve against) or the result
+   wouldn't fit in outBuf. */
+bool image_resolve_path(const char *docFilePath, const char *imageRef, char *outBuf, int outBufSize);
+
+typedef struct ImageCache ImageCache; /* opaque; growable array of cached entries */
+void image_cache_init(ImageCache **cache);
+void image_cache_free(ImageCache *cache);
+
+/* Looks up (lazily loading and caching on first sight, keyed by resolvedPath) the texture for an
+   already-resolved absolute path. Every call -- hit or miss -- cheaply re-stat()s the file and
+   compares mtime/existence against the cached entry, reloading or re-failing as needed, so an
+   image edited/created on disk while hush is open is picked up on the next lookup rather than
+   being stuck permanently broken or permanently stale. Returns false (outTexture/outW/outH
+   untouched) if the file can't be resolved/loaded. Never called by hush_test -- needs a live
+   SDL_Renderer and a real window/video subsystem, neither of which the headless test binary has. */
+bool image_cache_get(ImageCache *cache, SDL_Renderer *renderer, const char *resolvedPath,
+                      SDL_Texture **outTexture, int *outW, int *outH);
+
+#endif
diff --git a/src/lang.c b/src/lang.c
index d663033..df5e96c 100644
--- a/src/lang.c
+++ b/src/lang.c
@@ -50,6 +50,7 @@ static const char *KEY_NAMES[STR_COUNT] = {
     [STR_BLOCK_CODE] = "block.code",
     [STR_BLOCK_TASK] = "block.task",
     [STR_BLOCK_HR] = "block.hr",
+    [STR_BLOCK_IMAGE] = "block.image",
     [STR_QUICK_INSERT_EMPTY] = "quick_insert.empty",
 };

@@ -100,6 +101,7 @@ static const char *DEFAULTS[STR_COUNT] = {
     [STR_BLOCK_CODE] = "Code block",
     [STR_BLOCK_TASK] = "Task list",
     [STR_BLOCK_HR] = "Horizontal rule",
+    [STR_BLOCK_IMAGE] = "Image",
     [STR_QUICK_INSERT_EMPTY] = "No matches",
 };

diff --git a/src/lang.h b/src/lang.h
index c52c71f..def4014 100644
--- a/src/lang.h
+++ b/src/lang.h
@@ -51,6 +51,7 @@ typedef enum {
     STR_BLOCK_CODE,
     STR_BLOCK_TASK,
     STR_BLOCK_HR,
+    STR_BLOCK_IMAGE,
     STR_QUICK_INSERT_EMPTY,
     STR_COUNT,
 } StringId;
diff --git a/src/main.c b/src/main.c
index 7f00601..c0d6920 100644
--- a/src/main.c
+++ b/src/main.c
@@ -4,6 +4,7 @@
 #include "editor.h"
 #include "render.h"
 #include "layout_cache.h"
+#include "image_cache.h"
 #include "fonts.h"
 #include "theme.h"
 #include "app_mode.h"
@@ -172,6 +173,9 @@ int main(int argc, char **argv) {
     LayoutCache cache;
     layout_cache_init(&cache);

+    ImageCache *imageCache;
+    image_cache_init(&imageCache);
+
     double now0 = now_seconds();
     double blinkResetTime = now0;
     double lastFrameNow = now0;
@@ -427,7 +431,7 @@ int main(int argc, char **argv) {
         BlockTypeMenuClickResult menuClick;
         ModalAction action = render_frame(&ed, &cache, fonts, renderer, fi.mouseLeftPressed, (float)winW, (float)winH,
                                            deltaTime, caretBlinkT, mode, &toggledTaskBlock, &openPrompt, confirmForOpen,
-                                           &turnIntoMenuState, &quickInsertMenuState, &menuClick);
+                                           &turnIntoMenuState, &quickInsertMenuState, &menuClick, imageCache);
         SDL_RenderPresent(renderer);

         switch (action) {
@@ -459,6 +463,7 @@ int main(int argc, char **argv) {
     }

     layout_cache_free(&cache);
+    image_cache_free(imageCache);
     editor_free(&ed);
     fonts_unload(fonts);
     lang_free();
diff --git a/src/markdown_io.c b/src/markdown_io.c
index aba932a..8220765 100644
--- a/src/markdown_io.c
+++ b/src/markdown_io.c
@@ -47,6 +47,23 @@ static int is_hr_line(const char *line, int len) {
     return n >= 3;
 }

+/* Whole-line image: "![alt](url)" and nothing else (trailing spaces/tabs tolerated, matching
+   is_hr_line's tolerance). A non-empty URL is required. Mixed inline images embedded in prose
+   are explicitly out of scope -- fall through to BLOCK_PARAGRAPH like today. */
+static int is_image_line(const char *line, int len, int *altStart, int *altLen, int *urlStart, int *urlLen) {
+    while (len > 0 && (line[len - 1] == ' ' || line[len - 1] == '\t')) len--;
+    if (len < 5 || line[0] != '!' || line[1] != '[') return 0;
+    int closeBracket = -1;
+    for (int i = 2; i < len; i++) { if (line[i] == ']') { closeBracket = i; break; } }
+    if (closeBracket < 0 || closeBracket + 1 >= len || line[closeBracket + 1] != '(') return 0;
+    if (line[len - 1] != ')') return 0;
+    *altStart = 2;
+    *altLen = closeBracket - 2;
+    *urlStart = closeBracket + 2;
+    *urlLen = (len - 1) - *urlStart;
+    return *urlLen > 0;
+}
+
 /* ATX heading: 1-6 '#' followed by a space. Rejects 7+ '#' (not a heading per CommonMark). */
 static int match_heading_prefix(const char *line, int len, int *level, int *prefixLen) {
     int n = 0;
@@ -85,7 +102,7 @@ bool document_load_file(Document *doc, const char *path) {
     fclose(f);
     buf[readN] = '\0';

-    for (int i = 0; i < doc->count; i++) { sb_free(&doc->blocks[i].text); sb_free(&doc->blocks[i].lang); }
+    for (int i = 0; i < doc->count; i++) { sb_free(&doc->blocks[i].text); sb_free(&doc->blocks[i].lang); sb_free(&doc->blocks[i].alt); }
     doc->count = 0;

     int pos = 0;
@@ -130,6 +147,13 @@ bool document_load_file(Document *doc, const char *path) {
             continue;
         }

+        int altStart, altLen, urlStart, urlLen;
+        if (is_image_line(line, lineLen, &altStart, &altLen, &urlStart, &urlLen)) {
+            document_insert_block(doc, doc->count, BLOCK_IMAGE, line + urlStart, urlLen);
+            if (altLen > 0) sb_append(&doc->blocks[doc->count - 1].alt, line + altStart, altLen);
+            continue;
+        }
+
         if (is_blank_line(line, lineLen)) continue;

         int prefixLen, level, checked;
@@ -187,6 +211,13 @@ void document_serialize(Document *doc, StrBuf *out) {
             }
             case BLOCK_QUOTE: sb_append(out, "> ", 2); sb_append(out, b->text.data, b->text.len); break;
             case BLOCK_HR: sb_append(out, "---", 3); break;
+            case BLOCK_IMAGE:
+                sb_append(out, "![", 2);
+                sb_append(out, b->alt.data, b->alt.len);
+                sb_append(out, "](", 2);
+                sb_append(out, b->text.data, b->text.len);
+                sb_append_char(out, ')');
+                break;
             case BLOCK_CODE:
                 sb_append(out, "```", 3);
                 sb_append(out, b->lang.data, b->lang.len);
diff --git a/src/render.c b/src/render.c
index f8af359..d81592b 100644
--- a/src/render.c
+++ b/src/render.c
@@ -8,6 +8,7 @@
 #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>
@@ -31,6 +32,9 @@ typedef struct {
     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
@@ -289,6 +293,7 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
         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);
@@ -375,6 +380,54 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
                 }
             }
             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);
         } else {
             InlineRunList runs;
             inline_parse(b->text.data, b->text.len, &runs);
@@ -732,7 +785,7 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts,
                           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) {
+                          BlockTypeMenuClickResult *outMenuClick, ImageCache *imageCache) {
     g_scratchPos = 0;
     g_strikeMarkCount = 0;
     layout_cache_reset(cache);
@@ -743,7 +796,8 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts,
     if (contentWidth < 100.0f) contentWidth = 100.0f;

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

     Clay_BeginLayout();

diff --git a/src/render.h b/src/render.h
index 164a208..c507c83 100644
--- a/src/render.h
+++ b/src/render.h
@@ -6,6 +6,7 @@
 #include "app_mode.h"
 #include "fonts.h"
 #include "clay.h"
+#include "image_cache.h"
 #include <SDL.h>

 /* Declared (not defined) here because vendor/clay_renderer_SDL2.c has no header of its own;
@@ -38,11 +39,12 @@ Clay_Dimensions hush_measure_text(Clay_StringSlice text, Clay_TextElementConfig
    MODE_EDITING) for the "@" quick-insert popup, also anchored to ed->cursorBlock -- caller is
    responsible for the two never being open at once. `outMenuClick` (never NULL) is reset every
    call and filled in with whichever block-type menu row/icon/outside-click the frame's click
-   resolved to, if any -- caller applies the result after render_frame returns. */
+   resolved to, if any -- caller applies the result after render_frame returns. `imageCache` is
+   the process-lifetime image cache (see image_cache.h) used to render BLOCK_IMAGE blocks. */
 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);
+                          BlockTypeMenuClickResult *outMenuClick, ImageCache *imageCache);

 #endif
diff --git a/src/test_main.c b/src/test_main.c
index 3b80a7b..f7cbc3f 100644
--- a/src/test_main.c
+++ b/src/test_main.c
@@ -10,6 +10,7 @@
 #include "theme.h"
 #include "keymap.h"
 #include "block_type_menu.h"
+#include "image_cache.h"
 #include <SDL.h>
 #include <stdio.h>
 #include <string.h>
@@ -609,6 +610,99 @@ static void test_horizontal_rule_via_space(void) {
     editor_free(&ed3);
 }

+static void test_image_markdown(void) {
+    EditorState ed;
+    editor_init(&ed);
+    sb_clear(&ed.doc.blocks[0].text);
+    sb_append(&ed.doc.blocks[0].text, "![alt text](path/to/img.png)", 28);
+    CHECK(document_save_file(&ed.doc, "test_image.md"), "save succeeds");
+
+    EditorState ed2;
+    editor_init(&ed2);
+    CHECK(editor_load(&ed2, "test_image.md"), "load succeeds");
+    CHECK(ed2.doc.count == 1 && ed2.doc.blocks[0].type == BLOCK_IMAGE, "a whole-line '![alt](url)' parses to BLOCK_IMAGE");
+    CHECK(text_eq(&ed2.doc.blocks[0], "path/to/img.png"), "the block's text holds the url");
+    CHECK(ed2.doc.blocks[0].alt.len == 8 && memcmp(ed2.doc.blocks[0].alt.data, "alt text", 8) == 0, "the block's alt holds the alt text");
+
+    CHECK(document_save_file(&ed2.doc, "test_image2.md"), "re-save succeeds");
+    EditorState ed3b;
+    editor_init(&ed3b);
+    CHECK(editor_load(&ed3b, "test_image2.md"), "re-load succeeds");
+    CHECK(ed3b.doc.blocks[0].type == BLOCK_IMAGE && text_eq(&ed3b.doc.blocks[0], "path/to/img.png")
+              && ed3b.doc.blocks[0].alt.len == 8, "the image round-trips losslessly through a second save/load");
+    editor_free(&ed);
+    editor_free(&ed2);
+    editor_free(&ed3b);
+
+    EditorState ed5;
+    editor_init(&ed5);
+    /* document_save_file always writes exactly what's in the block's text, so to test the *loader's*
+       trailing-whitespace tolerance, write the raw file by hand instead. */
+    FILE *f = fopen("test_image4.md", "wb");
+    fputs("![alt](url.png)   \n", f);
+    fclose(f);
+    CHECK(editor_load(&ed5, "test_image4.md"), "load succeeds");
+    CHECK(ed5.doc.blocks[0].type == BLOCK_IMAGE && text_eq(&ed5.doc.blocks[0], "url.png"),
+          "trailing spaces/tabs after the closing ')' are tolerated, matching is_hr_line's tolerance");
+    editor_free(&ed5);
+
+    EditorState ed6;
+    editor_init(&ed6);
+    f = fopen("test_image5.md", "wb");
+    fputs("![]()\n", f);
+    fclose(f);
+    CHECK(editor_load(&ed6, "test_image5.md"), "load succeeds");
+    CHECK(ed6.doc.blocks[0].type == BLOCK_PARAGRAPH, "an empty url ('![]()') is not a valid image -- falls through to BLOCK_PARAGRAPH");
+    editor_free(&ed6);
+
+    EditorState ed7;
+    editor_init(&ed7);
+    f = fopen("test_image6.md", "wb");
+    fputs("![alt](url.png) trailing text\n", f);
+    fclose(f);
+    CHECK(editor_load(&ed7, "test_image6.md"), "load succeeds");
+    CHECK(ed7.doc.blocks[0].type == BLOCK_PARAGRAPH, "an image followed by other text on the same line is not a whole-line image -- stays BLOCK_PARAGRAPH");
+    editor_free(&ed7);
+}
+
+static void test_image_enter_does_not_split_url(void) {
+    EditorState ed;
+    editor_init(&ed);
+    type_str(&ed, "http://example.com/a.png");
+    editor_turn_into_apply(&ed, BLOCK_IMAGE);
+    CHECK(ed.doc.blocks[0].type == BLOCK_IMAGE, "turn-into converts to an image block");
+    ed.cursorOffset = 10; /* mid-string, e.g. right after "http://exa" */
+    ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
+    editor_enter(&ed);
+    CHECK(ed.doc.blocks[0].type == BLOCK_IMAGE, "the image block itself is unchanged in type");
+    CHECK(text_eq(&ed.doc.blocks[0], "http://example.com/a.png"),
+          "Enter mid-url does NOT split/truncate the url -- it stays fully intact");
+    CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH && text_eq(&ed.doc.blocks[1], ""),
+          "a fresh empty paragraph is inserted right after it instead");
+    CHECK(ed.cursorBlock == 1 && ed.cursorOffset == 0, "the cursor moves into that fresh paragraph");
+    editor_free(&ed);
+}
+
+static void test_image_resolve_path(void) {
+    char buf[512];
+
+    CHECK(image_resolve_path("/home/user/doc.md", "/etc/passwd", buf, sizeof buf) && strcmp(buf, "/etc/passwd") == 0,
+          "an absolute imageRef passes through unchanged, ignoring docFilePath");
+    CHECK(image_resolve_path(NULL, "/etc/passwd", buf, sizeof buf) && strcmp(buf, "/etc/passwd") == 0,
+          "an absolute imageRef resolves fine even with a NULL docFilePath");
+
+    CHECK(image_resolve_path("/home/user/notes/doc.md", "img/a.png", buf, sizeof buf)
+              && strcmp(buf, "/home/user/notes/img/a.png") == 0,
+          "a relative imageRef joins against the directory of docFilePath");
+    CHECK(image_resolve_path("doc.md", "a.png", buf, sizeof buf) && strcmp(buf, "a.png") == 0,
+          "docFilePath with no directory component (bare filename, CWD) resolves the ref as-is");
+
+    CHECK(!image_resolve_path(NULL, "img/a.png", buf, sizeof buf),
+          "a relative imageRef is unresolvable when docFilePath is NULL (unsaved buffer)");
+    CHECK(!image_resolve_path("/home/user/doc.md", "", buf, sizeof buf), "an empty imageRef is unresolvable");
+    CHECK(!image_resolve_path("/home/user/doc.md", NULL, buf, sizeof buf), "a NULL imageRef is unresolvable");
+}
+
 static void test_task_list(void) {
     EditorState ed;
     editor_init(&ed);
@@ -840,6 +934,20 @@ static void test_block_type_menu(void) {
     CHECK(ed4.doc.blocks[0].lang.len == 0, "lang is cleared when leaving BLOCK_CODE");
     editor_free(&ed4);

+    /* --- the critical regression: leaving BLOCK_IMAGE clears alt --- */
+    EditorState ed7;
+    editor_init(&ed7);
+    type_str(&ed7, "some/path.png");
+    editor_turn_into_apply(&ed7, BLOCK_IMAGE);
+    CHECK(ed7.doc.blocks[0].type == BLOCK_IMAGE, "turn-into converts to an image block");
+    CHECK(text_eq(&ed7.doc.blocks[0], "some/path.png"), "turn-into preserves the existing text as the url");
+    sb_append(&ed7.doc.blocks[0].alt, "a photo", 7);
+    CHECK(ed7.doc.blocks[0].alt.len == 7, "alt text is set up for the regression check");
+    editor_turn_into_apply(&ed7, BLOCK_H4);
+    CHECK(ed7.doc.blocks[0].type == BLOCK_H4, "turn-into converts the (former) image block");
+    CHECK(ed7.doc.blocks[0].alt.len == 0, "alt is cleared when leaving BLOCK_IMAGE");
+    editor_free(&ed7);
+
     /* --- undo/redo --- */
     EditorState ed5;
     editor_init(&ed5);
@@ -893,6 +1001,9 @@ int main(void) {
     test_heading_levels_round_trip();
     test_horizontal_rule();
     test_horizontal_rule_via_space();
+    test_image_markdown();
+    test_image_enter_does_not_split_url();
+    test_image_resolve_path();
     test_task_list();
     test_task_list_load_variants();
     test_code_fence_with_language();
diff --git a/src/theme.h b/src/theme.h
index 83bf3b6..acacc4c 100644
--- a/src/theme.h
+++ b/src/theme.h
@@ -86,4 +86,7 @@ int theme_scaled_font_size(int baseSize);
 #define BLOCK_TYPE_MENU_GAP 4
 #define QUICK_INSERT_MENU_WIDTH 220

+#define IMAGE_MAX_HEIGHT 480.0f
+#define IMAGE_FALLBACK_HEIGHT 80.0f
+
 #endif
diff --git a/vendor/clay_renderer_SDL2.c b/vendor/clay_renderer_SDL2.c
index a6b0fa7..dbd4f32 100644
--- a/vendor/clay_renderer_SDL2.c
+++ b/vendor/clay_renderer_SDL2.c
@@ -1,10 +1,13 @@
-/* Vendored from renderers/SDL2/clay_renderer_SDL2.c in github.com/nicbarker/clay (MIT), with two
+/* Vendored from renderers/SDL2/clay_renderer_SDL2.c in github.com/nicbarker/clay (MIT), with three
    deliberate patches from upstream -- keep these if re-vendoring a newer version:
    1. Clay_SDL2_Render's `static` qualifier is stripped so render.c (a separate translation unit)
       can call it, matching how Clay_Raylib_Render was callable before this migration.
    2. The local SDL2_Font typedef below is removed; the canonical definition now lives in
       src/fonts.h (included by src/clay_impl.c before this file), so there's exactly one
-      definition instead of two hand-synced copies. */
+      definition instead of two hand-synced copies.
+   3. CLAY_RENDER_COMMAND_TYPE_IMAGE treats imageData as an already-created, long-lived
+      SDL_Texture* (owned/cached by src/image_cache.c) and just SDL_RenderCopy's it, instead of
+      upstream's SDL_CreateTextureFromSurface+SDL_DestroyTexture on every single frame. */
 /* clay.h is already included by src/clay_impl.c before this file, same as the raylib renderer. */
 #include <SDL.h>
 #include <SDL_ttf.h>
@@ -332,9 +335,12 @@ void Clay_SDL2_Render(SDL_Renderer *renderer, Clay_RenderCommandArray renderComm
                 break;
             }
             case CLAY_RENDER_COMMAND_TYPE_IMAGE: {
+                /* Patch #3 (see the file-header comment): imageData is an already-created,
+                   long-lived SDL_Texture* owned by src/image_cache.c, not an SDL_Surface* to
+                   convert (and destroy) on every single frame -- upstream's create+destroy-per-
+                   frame pattern is a real perf problem once images actually render repeatedly. */
                 Clay_ImageRenderData *config = &renderCommand->renderData.image;
-
-                SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, config->imageData);
+                SDL_Texture *texture = (SDL_Texture *)config->imageData;

                 SDL_Rect destination = (SDL_Rect){
                     .x = boundingBox.x,
@@ -344,8 +350,6 @@ void Clay_SDL2_Render(SDL_Renderer *renderer, Clay_RenderCommandArray renderComm
                 };

                 SDL_RenderCopy(renderer, texture, NULL, &destination);
-
-                SDL_DestroyTexture(texture);
                 break;
             }
             case CLAY_RENDER_COMMAND_TYPE_BORDER: {