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 ""
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, /* ""; 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: "" 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, ";
+ 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, "", 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 '' 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(" \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(" 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: {