commits
tags
#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;
}