commit 598c19f23f10461751582c2c0fc3b735a467fb66
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Wed Aug 26 13:17:56 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Wed Aug 26 13:17:56 2026 +0200
Add GFM table support
Tables are a contiguous run of BLOCK_TABLE_HEADER_CELL/BLOCK_TABLE_CELL
blocks, mirroring how bulleted/numbered lists are already N consecutive
same-type blocks. Reuses the existing cursor/selection/undo model, with
guards added so Enter/Backspace/Delete/cross-cell-selection and Turn Into
can't corrupt a table's row/column structure. Reachable via "@table"
quick-insert and the Turn Into menu.
Also fixes two rendering bugs found during follow-up testing: a table's
outer Clay wrapper collided with its own first header cell's ID (spamming
"element already declared" errors), and two adjacent same-width tables
with no separating block would render as one merged, misaligned grid.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
assets/lang/en.lang | 1 +
assets/lang/sv.lang | 1 +
src/block_type_menu.c | 10 ++
src/block_type_menu.h | 2 +-
src/document.c | 6 ++
src/document.h | 15 +++
src/editor.c | 163 +++++++++++++++++++++++++++-
src/editor.h | 17 +++
src/hittest.c | 9 ++
src/hittest.h | 7 ++
src/lang.c | 2 +
src/lang.h | 1 +
src/main.c | 32 +++++-
src/markdown_io.c | 175 +++++++++++++++++++++++++++++-
src/render.c | 112 ++++++++++++++++++--
src/test_main.c | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/undo.h | 2 +-
17 files changed, 826 insertions(+), 17 deletions(-)
diff --git a/assets/lang/en.lang b/assets/lang/en.lang
index 3702d97..5d6f275 100644
--- a/assets/lang/en.lang
+++ b/assets/lang/en.lang
@@ -65,6 +65,7 @@ block.code = Code block
block.task = Task list
block.hr = Horizontal rule
block.image = Image
+block.table = Table
# 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 00334fa..dae3432 100644
--- a/assets/lang/sv.lang
+++ b/assets/lang/sv.lang
@@ -62,6 +62,7 @@ block.code = Kodblock
block.task = Checklista
block.hr = Horisontell linje
block.image = Bild
+block.table = Tabell
# 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 8c897f2..860d278 100644
--- a/src/block_type_menu.c
+++ b/src/block_type_menu.c
@@ -20,6 +20,16 @@ const BlockTypeMenuItem BLOCK_TYPE_MENU_ITEMS[BLOCK_TYPE_MENU_ITEM_COUNT] = {
{ 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 },
+ /* BLOCK_TABLE_HEADER_CELL doubles as a sentinel: inserting a table means creating rows*cols
+ new blocks, which doesn't fit editor_quick_insert_apply's/editor_turn_into_apply's
+ single-block-type-change signature -- main.c's apply call sites (both quick-insert's and
+ Turn Into's) check for this exact type before calling either and route to
+ editor_insert_table instead. Unlike every other Turn-Into target, this one does NOT
+ preserve the block's existing content (there's no meaningful way to fold one block's text
+ into a whole new grid) -- it's offered anyway since a working "add a table" affordance
+ from the left-margin menu was worth the one documented exception, not excluded like
+ BLOCK_HR (which is excluded because quick-insert can't reach a preservable state at all). */
+ { BLOCK_TABLE_HEADER_CELL, STR_BLOCK_TABLE, "TBL", "table grid rows columns", 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 866a25e..4c0ebfc 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 13
+#define BLOCK_TYPE_MENU_ITEM_COUNT 14
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 580e03a..2afe44c 100644
--- a/src/document.c
+++ b/src/document.c
@@ -39,6 +39,9 @@ void document_insert_block(Document *doc, int index, BlockType type, const char
sb_append(&b->text, text, len);
sb_init(&b->lang);
sb_init(&b->alt);
+ b->tableCol = 0;
+ b->tableCols = 0;
+ b->tableAlign = 0;
}
void document_clone(Document *dst, const Document *src) {
@@ -53,6 +56,9 @@ void document_clone(Document *dst, const Document *src) {
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);
+ dst->blocks[i].tableCol = src->blocks[i].tableCol;
+ dst->blocks[i].tableCols = src->blocks[i].tableCols;
+ dst->blocks[i].tableAlign = src->blocks[i].tableAlign;
}
}
diff --git a/src/document.h b/src/document.h
index eed90e2..6fadef3 100644
--- a/src/document.h
+++ b/src/document.h
@@ -19,6 +19,8 @@ typedef enum {
BLOCK_TASK_UNCHECKED, /* "- [ ] text" */
BLOCK_TASK_CHECKED, /* "- [x] text" */
BLOCK_IMAGE, /* ""; text holds the url, alt holds the alt text */
+ BLOCK_TABLE_HEADER_CELL, /* one cell of a table's header row; text holds the cell's content */
+ BLOCK_TABLE_CELL, /* one cell of a table's body row; text holds the cell's content */
} BlockType;
typedef struct {
@@ -26,6 +28,13 @@ typedef struct {
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_TABLE_HEADER_CELL/BLOCK_TABLE_CELL only. A table is a contiguous run of these blocks
+ (like a bulleted list is a run of BLOCK_BULLET blocks) -- these fields let a cell know its
+ position without needing a separate table-container block or lookahead. tableCol resetting
+ to 0 marks a new row; tableCol == tableCols-1 marks the last cell of a row. */
+ int tableCol; /* 0-indexed column within this cell's row */
+ int tableCols; /* total columns in this cell's table */
+ char tableAlign; /* 'l' (default), 'c', or 'r', from the delimiter row */
} Block;
typedef struct {
@@ -40,6 +49,12 @@ static inline int block_type_is_continuable(BlockType t) {
|| t == BLOCK_TASK_UNCHECKED || t == BLOCK_TASK_CHECKED;
}
+/* True for a table cell (header or body) -- Enter/Backspace/Delete/vertical-nav/Turn-Into all
+ need to treat these specially rather than falling into their generic per-block-type paths. */
+static inline int block_type_is_table_cell(BlockType t) {
+ return t == BLOCK_TABLE_HEADER_CELL || t == BLOCK_TABLE_CELL;
+}
+
void document_init(Document *doc);
void document_free(Document *doc);
diff --git a/src/editor.c b/src/editor.c
index 24784ca..a0ab4e8 100644
--- a/src/editor.c
+++ b/src/editor.c
@@ -143,7 +143,26 @@ static bool delete_selection_if_any(EditorState *ed) {
if (!editor_has_selection(ed)) return false;
int sb, so, eb, eo;
editor_selection_range(ed, &sb, &so, &eb, &eo);
- if (sb == eb) {
+
+ bool spansTableCell = false;
+ for (int i = sb; i <= eb; i++) {
+ if (block_type_is_table_cell(ed->doc.blocks[i].type)) { spansTableCell = true; break; }
+ }
+
+ if (spansTableCell) {
+ /* Never merge text across cells or remove a cell block here -- that would corrupt the
+ table's fixed rows*cols run (tableCol/tableCols arithmetic in rendering and
+ vertical-nav trusts a clean, unbroken sequence downstream). Clear each selected
+ block's own covered range in place instead -- for a same-cell selection (sb == eb)
+ this is identical to the plain-text path below; it only changes behavior once the
+ selection actually spans more than one block. */
+ for (int i = sb; i <= eb; i++) {
+ Block *b = &ed->doc.blocks[i];
+ int start = (i == sb) ? so : 0;
+ int end = (i == eb) ? eo : b->text.len;
+ sb_delete(&b->text, start, end - start);
+ }
+ } else if (sb == eb) {
sb_delete(&ed->doc.blocks[sb].text, so, eo - so);
} else {
Block *startBlock = &ed->doc.blocks[sb];
@@ -219,6 +238,24 @@ void editor_paste(EditorState *ed, const char *text, int len) {
free(tmp);
}
+/* Removes every block belonging to the table that starts at `headerFirstIdx` (the header row's
+ first cell), replacing them all with a single empty BLOCK_PARAGRAPH at that position. This is
+ the one deliberate, discoverable way to remove a whole table (mirrors BLOCK_HR's
+ demote-then-remove precedent) -- needed because every other deleting gesture is guarded to
+ never touch a table's cell blocks at all. */
+static void remove_table_at(EditorState *ed, int headerFirstIdx) {
+ int cols = ed->doc.blocks[headerFirstIdx].tableCols;
+ int tableEnd = headerFirstIdx;
+ while (tableEnd < ed->doc.count && block_type_is_table_cell(ed->doc.blocks[tableEnd].type)
+ && ed->doc.blocks[tableEnd].tableCols == cols) {
+ tableEnd++;
+ }
+ for (int i = tableEnd - 1; i >= headerFirstIdx; i--) document_remove_block(&ed->doc, i);
+ document_insert_block(&ed->doc, headerFirstIdx, BLOCK_PARAGRAPH, "", 0);
+ ed->cursorBlock = headerFirstIdx;
+ ed->cursorOffset = 0;
+}
+
static void backspace_raw(EditorState *ed) {
Block *b = &ed->doc.blocks[ed->cursorBlock];
if (ed->cursorOffset > 0) {
@@ -226,6 +263,16 @@ static void backspace_raw(EditorState *ed) {
sb_delete(&b->text, prevStart, ed->cursorOffset - prevStart);
ed->cursorOffset = prevStart;
ed->dirty = true;
+ } else if (block_type_is_table_cell(b->type)) {
+ /* Checked before the generic b->type != BLOCK_PARAGRAPH demote branch below, or a cell
+ would silently become an orphaned paragraph via that existing path. No-op at any cell
+ except the table's very first (tableCol == 0 of the header row), which removes the
+ whole table -- never merge into the preceding block, that would smash two cells' (or
+ a cell and unrelated) text together across a structural boundary. */
+ if (b->type == BLOCK_TABLE_HEADER_CELL && b->tableCol == 0) {
+ remove_table_at(ed, ed->cursorBlock);
+ ed->dirty = true;
+ }
} else if (b->type != BLOCK_PARAGRAPH) {
if (b->type == BLOCK_CODE) {
for (int i = 0; i < b->text.len; i++) if (b->text.data[i] == '\n') b->text.data[i] = ' ';
@@ -235,7 +282,9 @@ static void backspace_raw(EditorState *ed) {
}
b->type = BLOCK_PARAGRAPH;
ed->dirty = true;
- } else if (ed->cursorBlock > 0) {
+ } else if (ed->cursorBlock > 0 && !block_type_is_table_cell(ed->doc.blocks[ed->cursorBlock - 1].type)) {
+ /* Symmetric guard: never merge an ordinary block's text into a preceding table cell
+ either -- same principle, just the other direction. */
Block *prev = &ed->doc.blocks[ed->cursorBlock - 1];
int mergeAt = prev->text.len;
sb_append(&prev->text, b->text.data, b->text.len);
@@ -264,7 +313,12 @@ static void delete_forward_raw(EditorState *ed) {
int nlen = utf8_next_len(b->text.data, ed->cursorOffset, b->text.len);
sb_delete(&b->text, ed->cursorOffset, nlen);
ed->dirty = true;
- } else if (ed->cursorBlock < ed->doc.count - 1) {
+ } else if (ed->cursorBlock < ed->doc.count - 1 && !block_type_is_table_cell(b->type)
+ && !block_type_is_table_cell(ed->doc.blocks[ed->cursorBlock + 1].type)) {
+ /* Guarded both ways: if the current block is a table cell, merging the next block into
+ it would smash foreign text into a cell; if the *next* block is a table cell (even
+ though the current one isn't), merging it away would shrink that row out from under
+ the fixed tableCols arithmetic everything downstream trusts. Both are no-ops. */
Block *next = &ed->doc.blocks[ed->cursorBlock + 1];
if (b->type == BLOCK_CODE && next->type != BLOCK_CODE) {
sb_append_char(&b->text, '\n');
@@ -298,7 +352,18 @@ static void enter_raw(EditorState *ed) {
int lineEnd = ed->cursorOffset;
while (lineEnd < b->text.len && b->text.data[lineEnd] != '\n') lineEnd++;
- if (lineEnd - lineStart == 3 && memcmp(b->text.data + lineStart, "```", 3) == 0) {
+ bool closingFence = (lineEnd - lineStart == 3 && memcmp(b->text.data + lineStart, "```", 3) == 0);
+ /* Also exit on Enter-on-an-already-blank-trailing-line ("double Enter"), mirroring the
+ list-exit precedent below (block_type_is_continuable + empty text -> demote). This is
+ the only *reliable* keyboard-only way to leave a code block: producing a literal
+ backtick to type a real closing fence is a dead-key combination on some layouts (e.g.
+ Swedish, ` + space) that's easy to get stuck on, exactly like the already-documented
+ Ctrl+/ layout gap elsewhere in this codebase -- don't make escaping a code block
+ depend on it too. b->text.len > 0 excludes a just-created empty code block, so a
+ single Enter there still just adds a blank first line rather than immediately exiting. */
+ bool doubleEnterExit = (lineStart == lineEnd && ed->cursorOffset == b->text.len && b->text.len > 0);
+
+ if (closingFence || doubleEnterExit) {
int removeFrom = lineStart > 0 ? lineStart - 1 : lineStart;
sb_delete(&b->text, removeFrom, b->text.len - removeFrom);
document_insert_block(&ed->doc, ed->cursorBlock + 1, BLOCK_PARAGRAPH, "", 0);
@@ -357,6 +422,14 @@ static void enter_raw(EditorState *ed) {
return;
}
+ if (block_type_is_table_cell(b->type)) {
+ /* A cell's text is one line of GFM table syntax -- a literal '\n' would break it.
+ Enter behaves like Tab instead: move to the next cell, or exit the table. */
+ editor_table_move_cell(ed, 1);
+ ed->dirty = true;
+ return;
+ }
+
int splitAt = ed->cursorOffset;
int tailLen = b->text.len - splitAt;
BlockType newType = block_type_is_continuable(b->type) ? b->type : BLOCK_PARAGRAPH;
@@ -417,6 +490,33 @@ void editor_move_end(EditorState *ed, bool extend) {
}
void editor_move_vertical(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts, int dir, bool extend) {
+ Block *cur = &ed->doc.blocks[ed->cursorBlock];
+ if (block_type_is_table_cell(cur->type)) {
+ /* hittest_vertical's curLine +/- 1 is a flat, declaration-order step through
+ cache->lines -- correct for ordinary blocks (each produces a vertically-stacked
+ sequence of lines) but wrong for a grid: cells are declared row-by-row left-to-right,
+ so stepping one adjacent line index from a cell would move to the cell to the right,
+ not the one below. Jump directly to the same column in the next/previous row instead. */
+ if (ed->preferredX < 0.0f) {
+ float x, y, h;
+ if (hittest_caret(cache, &ed->doc, fonts, ed->cursorBlock, ed->cursorOffset, &x, &y, &h)) {
+ ed->preferredX = x;
+ } else {
+ ed->preferredX = 0.0f;
+ }
+ }
+ int target = ed->cursorBlock + dir * cur->tableCols;
+ if (target >= 0 && target < ed->doc.count && block_type_is_table_cell(ed->doc.blocks[target].type)
+ && ed->doc.blocks[target].tableCols == cur->tableCols) {
+ ed->cursorBlock = target;
+ ed->cursorOffset = hittest_offset_for_block_x(cache, &ed->doc, fonts, target, ed->preferredX);
+ snap_anchor_to_cursor(ed, extend);
+ return;
+ }
+ /* Stepped off the top/bottom of the table -- fall through to the normal path below,
+ which correctly steps to whatever non-table block precedes/follows it. */
+ }
+
if (ed->preferredX < 0.0f) {
float x, y, h;
if (hittest_caret(cache, &ed->doc, fonts, ed->cursorBlock, ed->cursorOffset, &x, &y, &h)) {
@@ -544,3 +644,58 @@ void editor_turn_into_apply(EditorState *ed, BlockType newType) {
snap_anchor_to_cursor(ed, false);
undo_manager_after_edit(&ed->undo, EDIT_TURN_INTO, ed->cursorBlock, ed->cursorOffset);
}
+
+void editor_table_move_cell(EditorState *ed, int dir) {
+ Block *b = &ed->doc.blocks[ed->cursorBlock];
+ if (!block_type_is_table_cell(b->type)) return;
+
+ int target = ed->cursorBlock + dir;
+ if (target >= 0 && target < ed->doc.count && block_type_is_table_cell(ed->doc.blocks[target].type)) {
+ ed->cursorBlock = target;
+ ed->cursorOffset = 0;
+ } else if (dir > 0) {
+ if (target < ed->doc.count) {
+ ed->cursorBlock = target;
+ } else {
+ document_insert_block(&ed->doc, ed->doc.count, BLOCK_PARAGRAPH, "", 0);
+ ed->cursorBlock = ed->doc.count - 1;
+ ed->dirty = true;
+ }
+ ed->cursorOffset = 0;
+ } else if (target >= 0) {
+ ed->cursorBlock = target;
+ ed->cursorOffset = 0;
+ }
+ /* else: Shift+Tab from the table's very first cell with nothing before it -- no-op. */
+
+ snap_anchor_to_cursor(ed, false);
+ reset_preferred_x(ed);
+}
+
+void editor_insert_table(EditorState *ed, int rows, int cols) {
+ undo_manager_before_edit(&ed->undo, EDIT_INSERT_TABLE, &ed->doc, ed->cursorBlock, ed->cursorOffset);
+
+ /* Like editor_quick_insert_apply, this is only ever called on the bare "@word" the user just
+ typed (see the Table sentinel handling in main.c) -- always replace ed->cursorBlock,
+ discarding whatever it held, rather than only when it happens to already be empty. */
+ int insertAt = ed->cursorBlock;
+ document_remove_block(&ed->doc, ed->cursorBlock);
+
+ for (int r = 0; r < rows; r++) {
+ for (int c = 0; c < cols; c++) {
+ BlockType type = (r == 0) ? BLOCK_TABLE_HEADER_CELL : BLOCK_TABLE_CELL;
+ int idx = insertAt + r * cols + c;
+ document_insert_block(&ed->doc, idx, type, "", 0);
+ Block *cell = &ed->doc.blocks[idx];
+ cell->tableCol = c;
+ cell->tableCols = cols;
+ cell->tableAlign = 'l';
+ }
+ }
+
+ ed->cursorBlock = insertAt;
+ ed->cursorOffset = 0;
+ ed->dirty = true;
+ snap_anchor_to_cursor(ed, false);
+ undo_manager_after_edit(&ed->undo, EDIT_INSERT_TABLE, ed->cursorBlock, ed->cursorOffset);
+}
diff --git a/src/editor.h b/src/editor.h
index 733d535..500acbf 100644
--- a/src/editor.h
+++ b/src/editor.h
@@ -51,6 +51,23 @@ bool editor_toggle_task(EditorState *ed, int blockIndex);
void editor_quick_insert_apply(EditorState *ed, BlockType newType);
void editor_turn_into_apply(EditorState *ed, BlockType newType);
+/* Inserts a rows*cols GFM table (row 0 as BLOCK_TABLE_HEADER_CELL, the rest BLOCK_TABLE_CELL,
+ empty text, left-aligned), undo-wrapped. Only ever called on the bare "@word" the user just
+ typed (like editor_quick_insert_apply) -- always replaces ed->cursorBlock, discarding
+ whatever it held. Cursor lands in the first header cell. */
+void editor_insert_table(EditorState *ed, int rows, int cols);
+
+/* Tab (dir=+1) / Shift+Tab (dir=-1) cell navigation: table cells are always laid out as one
+ contiguous, row-major run of blocks, so moving to the next/previous cell is just cursorBlock
+ +/- 1 (no row/col arithmetic needed) -- wrapping row-to-row happens for free. At either end of
+ the table, moves into the following/preceding block if one exists, or inserts a fresh empty
+ paragraph after the table if it's document-final. No-op if ed->cursorBlock isn't a table cell,
+ or (Shift+Tab from the table's very first cell) nowhere to go. Not undo-wrapped itself -- the
+ occasional "insert a trailing paragraph" side effect is treated as harmless bookkeeping, same
+ as how plain cursor movement never touches undo; callers that need it wrapped (none today) can
+ wrap it themselves. */
+void editor_table_move_cell(EditorState *ed, int dir);
+
/* `extend`: false moves the caret and collapses/resets the selection anchor to it (plain
movement); true moves only the caret, extending the selection from the existing anchor
(Shift+movement). */
diff --git a/src/hittest.c b/src/hittest.c
index 2500260..1ff8996 100644
--- a/src/hittest.c
+++ b/src/hittest.c
@@ -118,6 +118,15 @@ VerticalMoveResult hittest_vertical(LayoutCache *cache, Document *doc, SDL2_Font
return r;
}
+int hittest_offset_for_block_x(LayoutCache *cache, Document *doc, SDL2_Font *fonts, int blockIndex, float targetX) {
+ for (int i = 0; i < cache->lineCount; i++) {
+ if (cache->lines[i].blockIndex == blockIndex) {
+ return find_offset_in_line(cache, doc, fonts, i, targetX);
+ }
+ }
+ return 0;
+}
+
/* Screen-space x of byte `offset` within visual line `line` (whose Clay element data,
`lineData`, the caller already has). Shared by hittest_caret and hittest_line_range_box. */
static float x_at_offset_in_line(LayoutCache *cache, Document *doc, SDL2_Font *fonts, CachedLine *line, Clay_ElementData lineData, int offset) {
diff --git a/src/hittest.h b/src/hittest.h
index c5a6dbb..1a48f47 100644
--- a/src/hittest.h
+++ b/src/hittest.h
@@ -25,6 +25,13 @@ typedef struct {
close as possible to `preferredX` (screen-space x). */
VerticalMoveResult hittest_vertical(LayoutCache *cache, Document *doc, SDL2_Font *fonts, int blockIndex, int offset, float preferredX, int dir);
+/* Resolves screen-space x to a byte offset within `blockIndex`'s *first* registered visual line
+ (a table cell's own vertical-nav jump lands here -- equal-width narrow columns wrap more
+ readily than most block types, so not preserving exact wrapped-row position across a jump to
+ a different cell is a deliberate simplification). Returns 0 if the block has no registered
+ line yet (e.g. very first frame). */
+int hittest_offset_for_block_x(LayoutCache *cache, Document *doc, SDL2_Font *fonts, int blockIndex, float targetX);
+
/* Fills the caret's screen box for (blockIndex, offset). Returns 0 if the layout has no
matching entry yet (e.g. very first frame). */
int hittest_caret(LayoutCache *cache, Document *doc, SDL2_Font *fonts, int blockIndex, int offset, float *outX, float *outY, float *outHeight);
diff --git a/src/lang.c b/src/lang.c
index df5e96c..ad29a75 100644
--- a/src/lang.c
+++ b/src/lang.c
@@ -51,6 +51,7 @@ static const char *KEY_NAMES[STR_COUNT] = {
[STR_BLOCK_TASK] = "block.task",
[STR_BLOCK_HR] = "block.hr",
[STR_BLOCK_IMAGE] = "block.image",
+ [STR_BLOCK_TABLE] = "block.table",
[STR_QUICK_INSERT_EMPTY] = "quick_insert.empty",
};
@@ -102,6 +103,7 @@ static const char *DEFAULTS[STR_COUNT] = {
[STR_BLOCK_TASK] = "Task list",
[STR_BLOCK_HR] = "Horizontal rule",
[STR_BLOCK_IMAGE] = "Image",
+ [STR_BLOCK_TABLE] = "Table",
[STR_QUICK_INSERT_EMPTY] = "No matches",
};
diff --git a/src/lang.h b/src/lang.h
index def4014..ce75ec2 100644
--- a/src/lang.h
+++ b/src/lang.h
@@ -52,6 +52,7 @@ typedef enum {
STR_BLOCK_TASK,
STR_BLOCK_HR,
STR_BLOCK_IMAGE,
+ STR_BLOCK_TABLE,
STR_QUICK_INSERT_EMPTY,
STR_COUNT,
} StringId;
diff --git a/src/main.c b/src/main.c
index c0d6920..114f160 100644
--- a/src/main.c
+++ b/src/main.c
@@ -331,7 +331,15 @@ int main(int argc, char **argv) {
if (idx == qiSelectedIndex) { chosenType = BLOCK_TYPE_MENU_ITEMS[i].type; break; }
idx++;
}
- editor_quick_insert_apply(&ed, chosenType);
+ /* BLOCK_TABLE_HEADER_CELL doubles as the Table row's sentinel --
+ inserting a table means creating rows*cols new blocks, which
+ doesn't fit editor_quick_insert_apply's single-block-type-change
+ signature, so it's intercepted here rather than passed through. */
+ if (chosenType == BLOCK_TABLE_HEADER_CELL) {
+ editor_insert_table(&ed, 2, 2);
+ } else {
+ editor_quick_insert_apply(&ed, chosenType);
+ }
quickInsertDismissed = true;
}
}
@@ -347,6 +355,9 @@ int main(int argc, char **argv) {
if (keymap_repeat_scancode(SDL_SCANCODE_RIGHT, now, &repRight)) editor_move_right(&ed, shift);
if (keymap_key_pressed_raw(SDLK_HOME)) editor_move_home(&ed, shift);
if (keymap_key_pressed_raw(SDLK_END)) editor_move_end(&ed, shift);
+ if (block_type_is_table_cell(ed.doc.blocks[ed.cursorBlock].type) && keymap_key_pressed_raw(SDLK_TAB)) {
+ editor_table_move_cell(&ed, shift ? -1 : 1);
+ }
}
bool overTurnIntoUi = point_in_element(CLAY_ID("TurnIntoIcon"), mousePos)
@@ -452,11 +463,26 @@ int main(int argc, char **argv) {
if (mode == MODE_EDITING && toggledTaskBlock >= 0) editor_toggle_task(&ed, toggledTaskBlock);
if (menuClick.turnIntoIconClicked) { turnIntoOpen = !turnIntoOpen; turnIntoAnchorBlock = ed.cursorBlock; }
- if (menuClick.turnIntoClickedType >= 0) { editor_turn_into_apply(&ed, (BlockType)menuClick.turnIntoClickedType); turnIntoOpen = false; }
+ if (menuClick.turnIntoClickedType >= 0) {
+ /* BLOCK_TABLE_HEADER_CELL doubles as the Table row's sentinel, same as in the
+ quick-insert path -- Turn-Into normally preserves a block's content, but there's
+ no meaningful way to preserve one block's text as a whole new grid, so this
+ discards it and inserts a fresh table instead, exactly like quick-insert does. */
+ if ((BlockType)menuClick.turnIntoClickedType == BLOCK_TABLE_HEADER_CELL) {
+ editor_insert_table(&ed, 2, 2);
+ } else {
+ editor_turn_into_apply(&ed, (BlockType)menuClick.turnIntoClickedType);
+ }
+ turnIntoOpen = false;
+ }
if (menuClick.turnIntoClickedOutside) turnIntoOpen = false;
if (menuClick.quickInsertClickedType >= 0) {
- editor_quick_insert_apply(&ed, (BlockType)menuClick.quickInsertClickedType);
+ if ((BlockType)menuClick.quickInsertClickedType == BLOCK_TABLE_HEADER_CELL) {
+ editor_insert_table(&ed, 2, 2);
+ } else {
+ editor_quick_insert_apply(&ed, (BlockType)menuClick.quickInsertClickedType);
+ }
quickInsertDismissed = true;
quickInsertOpen = false;
}
diff --git a/src/markdown_io.c b/src/markdown_io.c
index 8220765..b15aff5 100644
--- a/src/markdown_io.c
+++ b/src/markdown_io.c
@@ -64,6 +64,133 @@ static int is_image_line(const char *line, int len, int *altStart, int *altLen,
return *urlLen > 0;
}
+#define TABLE_MAX_COLS 64
+
+/* Splits a "|"-delimited row into cells: strips one leading/trailing '|' if present (GFM allows
+ both "| a | b |" and "a | b" forms), splits the remainder on '|' (no backslash-escape handling
+ -- matches every other matcher in this file), trims surrounding spaces/tabs from each cell.
+ Writes up to TABLE_MAX_COLS cells (byte ranges into `line`) into cellStart/cellLen. Returns the
+ cell count. */
+static int split_table_row(const char *line, int len, int *cellStart, int *cellLen) {
+ int start = 0, end = len;
+ if (end > start && line[start] == '|') start++;
+ if (end > start && line[end - 1] == '|') end--;
+
+ int count = 0;
+ int segStart = start;
+ for (int i = start; i <= end; i++) {
+ if (i < end && line[i] != '|') continue;
+ int s = segStart, e = i;
+ while (s < e && (line[s] == ' ' || line[s] == '\t')) s++;
+ while (e > s && (line[e - 1] == ' ' || line[e - 1] == '\t')) e--;
+ if (count < TABLE_MAX_COLS) {
+ cellStart[count] = s;
+ cellLen[count] = e - s;
+ count++;
+ }
+ segStart = i + 1;
+ }
+ return count;
+}
+
+/* A GFM table delimiter row: "|"-separated cells each matching "^:?-+:?$" (at least one '-',
+ optional leading/trailing ':' for alignment). Fills outAligns[i] with 'l'/'c'/'r' per cell
+ ('l' is also the default with no colons at all). Returns the cell count via outCols. */
+static int is_table_delimiter_row(const char *line, int len, int *outCols, char *outAligns) {
+ int cellStart[TABLE_MAX_COLS], cellLen[TABLE_MAX_COLS];
+ int cols = split_table_row(line, len, cellStart, cellLen);
+ if (cols == 0) return 0;
+ for (int i = 0; i < cols; i++) {
+ int s = cellStart[i], e = s + cellLen[i];
+ if (s >= e) return 0;
+ int leftColon = line[s] == ':';
+ int rightColon = line[e - 1] == ':';
+ int dashStart = s + (leftColon ? 1 : 0);
+ int dashEnd = e - (rightColon ? 1 : 0);
+ if (dashEnd <= dashStart) return 0;
+ for (int j = dashStart; j < dashEnd; j++) { if (line[j] != '-') return 0; }
+ outAligns[i] = rightColon ? (leftColon ? 'c' : 'r') : 'l';
+ }
+ *outCols = cols;
+ return 1;
+}
+
+/* Reads one line's bounds starting at *pos (same \r-stripping logic as the main load loop), does
+ NOT advance *pos -- a pure peek. */
+static void peek_line(const char *buf, int len, int pos, int *lineStart, int *lineEnd) {
+ int start = pos;
+ while (pos < len && buf[pos] != '\n') pos++;
+ int end = pos;
+ if (end > start && buf[end - 1] == '\r') end--;
+ *lineStart = start;
+ *lineEnd = end;
+}
+
+/* Advances *pos past one line (mirrors the main load loop's own line-consuming step). */
+static void consume_line(const char *buf, int len, int *pos) {
+ while (*pos < len && buf[*pos] != '\n') (*pos)++;
+ if (*pos < len) (*pos)++;
+}
+
+/* Tries to parse a GFM table starting with `headerLine` (already isolated, not yet consumed from
+ *pos) as its header row. Only commits (consuming lines for real, inserting blocks) if the very
+ next line validates as a delimiter row with a matching cell count -- GFM's own disambiguation
+ rule, so a line that merely contains '|' but isn't followed by a real delimiter row falls
+ through to BLOCK_PARAGRAPH untouched, same as before this function existed. Returns 1 if a
+ table was parsed (and *pos advanced past it), 0 otherwise (*pos untouched). */
+static int try_parse_table(Document *doc, const char *buf, int len, int *pos, const char *headerLine, int headerLineLen) {
+ int headerStart[TABLE_MAX_COLS], headerLen[TABLE_MAX_COLS];
+ int cols = split_table_row(headerLine, headerLineLen, headerStart, headerLen);
+ if (cols == 0) return 0;
+
+ int delimStart, delimEnd;
+ peek_line(buf, len, *pos, &delimStart, &delimEnd);
+ char aligns[TABLE_MAX_COLS];
+ int delimCols;
+ if (!is_table_delimiter_row(buf + delimStart, delimEnd - delimStart, &delimCols, aligns) || delimCols != cols) {
+ return 0;
+ }
+ consume_line(buf, len, pos); /* the delimiter row itself is not stored as a block */
+
+ for (int c = 0; c < cols; c++) {
+ document_insert_block(doc, doc->count, BLOCK_TABLE_HEADER_CELL, headerLine + headerStart[c], headerLen[c]);
+ Block *b = &doc->blocks[doc->count - 1];
+ b->tableCol = c;
+ b->tableCols = cols;
+ b->tableAlign = aligns[c];
+ }
+
+ for (;;) {
+ int rowStart, rowEnd;
+ peek_line(buf, len, *pos, &rowStart, &rowEnd);
+ if (rowStart >= len) break; /* EOF */
+ int rowLen = rowEnd - rowStart;
+ int isBlank = 1;
+ for (int i = 0; i < rowLen; i++) { if (buf[rowStart + i] != ' ' && buf[rowStart + i] != '\t') { isBlank = 0; break; } }
+ if (isBlank || !memchr(buf + rowStart, '|', (size_t)rowLen)) break;
+
+ int cellStart[TABLE_MAX_COLS], cellLen[TABLE_MAX_COLS];
+ int rowCols = split_table_row(buf + rowStart, rowLen, cellStart, cellLen);
+ consume_line(buf, len, pos);
+
+ for (int c = 0; c < cols; c++) {
+ /* Ragged rows (GFM spec): short rows are padded with empty cells, long rows are
+ truncated -- tableCols is trusted unconditionally downstream (rendering,
+ vertical-nav index arithmetic), so every row must end up with exactly `cols`
+ cells. */
+ const char *cellText = (c < rowCols) ? buf + rowStart + cellStart[c] : "";
+ int cellTextLen = (c < rowCols) ? cellLen[c] : 0;
+ document_insert_block(doc, doc->count, BLOCK_TABLE_CELL, cellText, cellTextLen);
+ Block *b = &doc->blocks[doc->count - 1];
+ b->tableCol = c;
+ b->tableCols = cols;
+ b->tableAlign = aligns[c];
+ }
+ }
+
+ return 1;
+}
+
/* 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;
@@ -168,6 +295,9 @@ bool document_load_file(Document *doc, const char *path) {
document_insert_block(doc, doc->count, BLOCK_QUOTE, line + 2, lineLen - 2);
} else if (match_numbered_prefix(line, lineLen, &prefixLen)) {
document_insert_block(doc, doc->count, BLOCK_NUMBERED, line + prefixLen, lineLen - prefixLen);
+ } else if (memchr(line, '|', (size_t)lineLen) && try_parse_table(doc, buf, len, &pos, line, lineLen)) {
+ /* try_parse_table already inserted the header+body cell blocks and advanced pos
+ past every line it consumed. */
} else {
document_insert_block(doc, doc->count, BLOCK_PARAGRAPH, line, lineLen);
}
@@ -182,13 +312,50 @@ bool document_load_file(Document *doc, const char *path) {
return true;
}
+/* Reconstructs the "| --- | :---: | ---: |"-style delimiter row for the table whose header row's
+ last cell is at block index headerLastIdx, from each header cell's tableAlign. A plain 'l'
+ (which also covers "no colons specified at all" -- both collapse to the same value on parse)
+ serializes back as bare "---", not ":---" -- a narrow, accepted round-trip fidelity loss since
+ both render identically. */
+static void serialize_table_delimiter_row(StrBuf *out, Document *doc, int headerLastIdx, int cols) {
+ int firstIdx = headerLastIdx - cols + 1;
+ sb_append_char(out, '|');
+ for (int c = 0; c < cols; c++) {
+ char align = doc->blocks[firstIdx + c].tableAlign;
+ sb_append(out, " ", 1);
+ if (align == 'c') sb_append_char(out, ':');
+ sb_append(out, "---", 3);
+ if (align == 'c' || align == 'r') sb_append_char(out, ':');
+ sb_append(out, " |", 2);
+ }
+}
+
void document_serialize(Document *doc, StrBuf *out) {
sb_clear(out);
int numberedRun = 0;
for (int i = 0; i < doc->count; i++) {
Block *b = &doc->blocks[i];
- if (i > 0) sb_append(out, "\n\n", 2);
+
+ int skipDefaultSeparator = 0;
+ if (i > 0) {
+ Block *prev = &doc->blocks[i - 1];
+ if (block_type_is_table_cell(prev->type) && block_type_is_table_cell(b->type) && prev->tableCols == b->tableCols) {
+ if (b->tableCol != 0) {
+ sb_append(out, " | ", 3);
+ } else {
+ sb_append_char(out, '\n');
+ if (prev->type == BLOCK_TABLE_HEADER_CELL) {
+ serialize_table_delimiter_row(out, doc, i - 1, prev->tableCols);
+ sb_append_char(out, '\n');
+ }
+ }
+ skipDefaultSeparator = 1;
+ }
+ }
+ if (i > 0 && !skipDefaultSeparator) sb_append(out, "\n\n", 2);
+
+ if (block_type_is_table_cell(b->type) && b->tableCol == 0) sb_append(out, "| ", 2);
switch (b->type) {
case BLOCK_H1: sb_append(out, "# ", 2); sb_append(out, b->text.data, b->text.len); break;
@@ -225,11 +392,17 @@ void document_serialize(Document *doc, StrBuf *out) {
sb_append(out, b->text.data, b->text.len);
sb_append(out, "\n```", 4);
break;
+ case BLOCK_TABLE_HEADER_CELL:
+ case BLOCK_TABLE_CELL:
+ sb_append(out, b->text.data, b->text.len);
+ break;
case BLOCK_PARAGRAPH:
default:
sb_append(out, b->text.data, b->text.len);
break;
}
+
+ if (block_type_is_table_cell(b->type) && b->tableCol == b->tableCols - 1) sb_append(out, " |", 2);
}
sb_append_char(out, '\n');
}
diff --git a/src/render.c b/src/render.c
index d81592b..8723835 100644
--- a/src/render.c
+++ b/src/render.c
@@ -169,7 +169,8 @@ static int compute_list_number(Document *doc, int blockIndex) {
}
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) {
+ 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; }
@@ -191,7 +192,7 @@ static void emit_wrapped_lines(RenderCtx *ctx, int blockIndex, const char *text,
.layout = {
.layoutDirection = CLAY_LEFT_TO_RIGHT,
.sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(lineHeight) },
- .childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
+ .childAlignment = { .x = alignX, .y = CLAY_ALIGN_Y_CENTER },
},
}) {
for (int si = 0; si < wl->count; si++) {
@@ -275,6 +276,81 @@ static void emit_code_block(RenderCtx *ctx, int blockIndex, const char *text, in
}
}
+/* 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];
@@ -376,7 +452,7 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
.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);
+ baseFontSize, boldBase, baseColor, b->type == BLOCK_TASK_CHECKED, CLAY_ALIGN_X_LEFT);
}
}
free(rr);
@@ -427,7 +503,7 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
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);
+ 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);
@@ -436,7 +512,7 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
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);
+ emit_wrapped_lines(ctx, blockIndex, b->text.data, rr, rc, avail, baseFontSize, boldBase, baseColor, false, CLAY_ALIGN_X_LEFT);
free(rr);
}
}
@@ -820,7 +896,25 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts,
emit_source_view(ed, contentWidth);
} else {
for (int i = 0; i < ed->doc.count; i++) {
- emit_block(&ctx, &ed->doc, 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);
+ }
}
}
}
@@ -860,7 +954,11 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts,
if (mode == MODE_EDITING) {
if (quickInsertMenu->open) {
emit_quick_insert_menu(&ctx, ed, quickInsertMenu, outMenuClick);
- } else {
+ } 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);
}
}
diff --git a/src/test_main.c b/src/test_main.c
index f7cbc3f..4a92125 100644
--- a/src/test_main.c
+++ b/src/test_main.c
@@ -133,6 +133,37 @@ static void test_code_fence(void) {
editor_free(&ed);
}
+static void test_code_fence_double_enter_exit(void) {
+ /* A literal closing "```" isn't the only way out -- on some keyboard layouts (e.g. Swedish)
+ backtick is a dead key, easy to get stuck on. Enter on an already-blank trailing line
+ must also exit, mirroring the list-exit precedent (empty item + Enter -> demote). */
+ EditorState ed;
+ editor_init(&ed);
+ type_str(&ed, "```");
+ editor_enter(&ed);
+ type_str(&ed, "line one");
+ editor_enter(&ed); /* embeds '\n', stays in the code block */
+ CHECK(ed.doc.blocks[0].type == BLOCK_CODE, "still in the code block after one Enter");
+ editor_enter(&ed); /* second Enter, on the now-blank trailing line -- should exit */
+ CHECK(ed.doc.blocks[0].type == BLOCK_CODE, "the code block itself is untouched");
+ CHECK(text_eq(&ed.doc.blocks[0], "line one"), "the blank trailing line is stripped, not left dangling in the code");
+ CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH, "double Enter exits to a new paragraph, same as a real closing fence");
+ CHECK(ed.cursorBlock == 1 && ed.cursorOffset == 0, "cursor lands in the new paragraph");
+ editor_free(&ed);
+
+ /* A single Enter on a freshly-created, still-empty code block must NOT immediately exit --
+ only a *second* Enter on an already-blank line (real double-Enter) should. */
+ EditorState ed2;
+ editor_init(&ed2);
+ type_str(&ed2, "```");
+ editor_enter(&ed2);
+ CHECK(ed2.doc.blocks[0].type == BLOCK_CODE, "opened a fresh, empty code block");
+ editor_enter(&ed2);
+ CHECK(ed2.doc.blocks[0].type == BLOCK_CODE, "a single Enter on an empty code block does not exit it");
+ CHECK(ed2.doc.count == 1, "no new block was created");
+ editor_free(&ed2);
+}
+
static void test_utf8_backspace(void) {
EditorState ed;
editor_init(&ed);
@@ -703,6 +734,249 @@ static void test_image_resolve_path(void) {
CHECK(!image_resolve_path("/home/user/doc.md", NULL, buf, sizeof buf), "a NULL imageRef is unresolvable");
}
+static void write_file(const char *path, const char *content) {
+ FILE *f = fopen(path, "wb");
+ fputs(content, f);
+ fclose(f);
+}
+
+static void test_table_parse_basic(void) {
+ write_file("test_table1.md", "| a | b | c |\n|---|---|---|\n| 1 | 2 | 3 |\n");
+ EditorState ed;
+ editor_init(&ed);
+ CHECK(editor_load(&ed, "test_table1.md"), "load succeeds");
+ CHECK(ed.doc.count == 6, "3 header cells + 3 body cells, no delimiter block");
+ CHECK(ed.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL && text_eq(&ed.doc.blocks[0], "a")
+ && ed.doc.blocks[0].tableCol == 0 && ed.doc.blocks[0].tableCols == 3,
+ "first header cell parses correctly");
+ CHECK(ed.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL && text_eq(&ed.doc.blocks[1], "b") && ed.doc.blocks[1].tableCol == 1,
+ "second header cell parses correctly");
+ CHECK(ed.doc.blocks[2].type == BLOCK_TABLE_HEADER_CELL && text_eq(&ed.doc.blocks[2], "c") && ed.doc.blocks[2].tableCol == 2,
+ "third header cell parses correctly");
+ CHECK(ed.doc.blocks[3].type == BLOCK_TABLE_CELL && text_eq(&ed.doc.blocks[3], "1") && ed.doc.blocks[3].tableCol == 0,
+ "first body cell parses correctly");
+ CHECK(ed.doc.blocks[5].type == BLOCK_TABLE_CELL && text_eq(&ed.doc.blocks[5], "3") && ed.doc.blocks[5].tableCol == 2,
+ "third body cell parses correctly");
+ editor_free(&ed);
+}
+
+static void test_table_alignment_parsing(void) {
+ write_file("test_table2.md", "| a | b | c |\n|:---|:---:|---:|\n| 1 | 2 | 3 |\n");
+ EditorState ed;
+ editor_init(&ed);
+ CHECK(editor_load(&ed, "test_table2.md"), "load succeeds");
+ CHECK(ed.doc.blocks[0].tableAlign == 'l', "':---' parses as left align");
+ CHECK(ed.doc.blocks[1].tableAlign == 'c', "':---:' parses as center align");
+ CHECK(ed.doc.blocks[2].tableAlign == 'r', "'---:' parses as right align");
+ editor_free(&ed);
+}
+
+static void test_table_delimiter_disambiguation(void) {
+ write_file("test_table3.md", "| not a table | just text\nmore text\n");
+ EditorState ed;
+ editor_init(&ed);
+ CHECK(editor_load(&ed, "test_table3.md"), "load succeeds");
+ CHECK(ed.doc.blocks[0].type == BLOCK_PARAGRAPH,
+ "a '|'-containing line NOT followed by a valid delimiter row falls through to BLOCK_PARAGRAPH");
+ editor_free(&ed);
+}
+
+static void test_table_ragged_rows(void) {
+ write_file("test_table4.md", "| a | b | c |\n|---|---|---|\n| short |\n| too | many | cells | here |\n");
+ EditorState ed;
+ editor_init(&ed);
+ CHECK(editor_load(&ed, "test_table4.md"), "load succeeds");
+ CHECK(ed.doc.count == 9, "3 header + 3 (padded) + 3 (truncated) = 9 cells");
+ CHECK(text_eq(&ed.doc.blocks[3], "short") && text_eq(&ed.doc.blocks[4], "") && text_eq(&ed.doc.blocks[5], ""),
+ "a short row is padded with empty cells");
+ CHECK(text_eq(&ed.doc.blocks[6], "too") && text_eq(&ed.doc.blocks[7], "many") && text_eq(&ed.doc.blocks[8], "cells"),
+ "a long row is truncated to the header's column count");
+ editor_free(&ed);
+}
+
+static void test_table_bullet_priority(void) {
+ write_file("test_table5.md", "- a | b\nmore | stuff\n");
+ EditorState ed;
+ editor_init(&ed);
+ CHECK(editor_load(&ed, "test_table5.md"), "load succeeds");
+ CHECK(ed.doc.blocks[0].type == BLOCK_BULLET, "a bullet line containing a literal '|' is still classified as a bullet, not a table candidate");
+ editor_free(&ed);
+}
+
+static void test_table_serialize_roundtrip(void) {
+ write_file("test_table6.md", "before\n\n| a | bb | ccc |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |\n\nafter\n");
+ EditorState ed;
+ editor_init(&ed);
+ CHECK(editor_load(&ed, "test_table6.md"), "load succeeds");
+ CHECK(ed.doc.count == 2 + 3 * 3, "before + 9 cells + after");
+ CHECK(document_save_file(&ed.doc, "test_table6b.md"), "save succeeds");
+
+ EditorState ed2;
+ editor_init(&ed2);
+ CHECK(editor_load(&ed2, "test_table6b.md"), "re-load succeeds");
+ CHECK(ed2.doc.count == ed.doc.count, "block count round-trips");
+ for (int i = 0; i < ed.doc.count; i++) {
+ CHECK(ed2.doc.blocks[i].type == ed.doc.blocks[i].type
+ && ed2.doc.blocks[i].text.len == ed.doc.blocks[i].text.len
+ && memcmp(ed2.doc.blocks[i].text.data, ed.doc.blocks[i].text.data, (size_t)ed.doc.blocks[i].text.len) == 0
+ && ed2.doc.blocks[i].tableCol == ed.doc.blocks[i].tableCol
+ && ed2.doc.blocks[i].tableCols == ed.doc.blocks[i].tableCols,
+ "each block round-trips through save/reload");
+ }
+ CHECK(ed2.doc.blocks[2].tableAlign == 'c', "center alignment round-trips (':---:' collapses correctly)");
+ CHECK(ed2.doc.blocks[3].tableAlign == 'r', "right alignment round-trips");
+ editor_free(&ed);
+ editor_free(&ed2);
+}
+
+static void test_table_insert(void) {
+ EditorState ed;
+ editor_init(&ed);
+ editor_insert_table(&ed, 2, 3);
+ CHECK(ed.doc.count == 6, "editor_insert_table(2, 3) creates 6 cell blocks, replacing the initial empty paragraph");
+ CHECK(ed.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL && ed.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL
+ && ed.doc.blocks[2].type == BLOCK_TABLE_HEADER_CELL,
+ "row 0 is all header cells");
+ CHECK(ed.doc.blocks[3].type == BLOCK_TABLE_CELL && ed.doc.blocks[4].type == BLOCK_TABLE_CELL && ed.doc.blocks[5].type == BLOCK_TABLE_CELL,
+ "row 1 is all body cells");
+ for (int i = 0; i < 6; i++) CHECK(ed.doc.blocks[i].tableCols == 3, "every cell knows the table has 3 columns");
+ CHECK(ed.cursorBlock == 0 && ed.cursorOffset == 0, "cursor lands in the first header cell");
+ editor_free(&ed);
+
+ EditorState ed2;
+ editor_init(&ed2);
+ type_str(&ed2, "@table");
+ editor_insert_table(&ed2, 1, 2);
+ CHECK(ed2.doc.count == 2, "like quick-insert, the '@word' trigger block's text is always discarded, not preserved");
+ CHECK(ed2.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL, "the table replaces it in place");
+ editor_free(&ed2);
+}
+
+static void test_table_tab_navigation(void) {
+ EditorState ed;
+ editor_init(&ed);
+ editor_insert_table(&ed, 2, 2); /* h0 h1 / b0 b1 */
+ CHECK(ed.cursorBlock == 0, "starts in cell 0");
+ editor_table_move_cell(&ed, 1);
+ CHECK(ed.cursorBlock == 1, "Tab moves to cell 1 (same row)");
+ editor_table_move_cell(&ed, 1);
+ CHECK(ed.cursorBlock == 2, "Tab from the last cell of row 0 wraps to cell 2 (row 1, col 0) -- free, since cells are row-major in the block array");
+ editor_table_move_cell(&ed, 1);
+ CHECK(ed.cursorBlock == 3, "Tab moves to cell 3");
+ editor_table_move_cell(&ed, 1);
+ CHECK(ed.cursorBlock == 4 && ed.doc.blocks[4].type == BLOCK_PARAGRAPH && text_eq(&ed.doc.blocks[4], ""),
+ "Tab from the table's very last cell exits it, inserting a fresh empty paragraph since none followed");
+ CHECK(ed.doc.count == 5, "the document grew by exactly one block");
+
+ editor_table_move_cell(&ed, -1);
+ CHECK(ed.cursorBlock == 4, "editor_table_move_cell is a full no-op once the cursor has left the table (not a table cell)");
+ ed.cursorBlock = 0;
+ editor_table_move_cell(&ed, -1);
+ CHECK(ed.cursorBlock == 0, "Shift+Tab from the table's very first cell is a no-op (nothing before it)");
+ editor_free(&ed);
+}
+
+static void test_table_enter_moves_like_tab(void) {
+ EditorState ed;
+ editor_init(&ed);
+ editor_insert_table(&ed, 1, 2);
+ type_str(&ed, "hello");
+ editor_enter(&ed);
+ CHECK(ed.cursorBlock == 1, "Enter inside a cell moves to the next cell, like Tab");
+ CHECK(text_eq(&ed.doc.blocks[0], "hello"), "no newline was inserted into the cell's text");
+ editor_free(&ed);
+}
+
+static void test_table_backspace_guards(void) {
+ EditorState ed;
+ editor_init(&ed);
+ editor_insert_table(&ed, 2, 2);
+ ed.cursorBlock = 3; /* last body cell, offset 0 */
+ ed.cursorOffset = 0;
+ ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
+ editor_backspace(&ed);
+ CHECK(ed.doc.count == 4 && ed.doc.blocks[3].type == BLOCK_TABLE_CELL,
+ "Backspace at offset 0 of a non-first cell is a no-op -- the table is untouched");
+
+ ed.cursorBlock = 0; /* the table's very first cell */
+ ed.cursorOffset = 0;
+ ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
+ editor_backspace(&ed);
+ CHECK(ed.doc.count == 1 && ed.doc.blocks[0].type == BLOCK_PARAGRAPH && text_eq(&ed.doc.blocks[0], ""),
+ "Backspace at offset 0 of the table's first cell removes the whole table, leaving one empty paragraph");
+ editor_free(&ed);
+}
+
+static void test_table_delete_forward_guards(void) {
+ EditorState ed;
+ editor_init(&ed);
+ editor_insert_table(&ed, 1, 2); /* h0 h1 */
+ ed.cursorBlock = 0;
+ ed.cursorOffset = ed.doc.blocks[0].text.len; /* end of cell 0 (empty, so offset 0) */
+ ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
+ editor_delete_forward(&ed);
+ CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL,
+ "Delete at end-of-text of a table cell is a no-op -- doesn't swallow the next cell");
+ editor_free(&ed);
+
+ EditorState ed2;
+ editor_init(&ed2);
+ type_str(&ed2, "before");
+ editor_enter(&ed2); /* fresh empty paragraph after "before", so the table (which always
+ replaces its target block) lands after "before" without touching it */
+ editor_insert_table(&ed2, 1, 2);
+ ed2.cursorBlock = 0;
+ ed2.cursorOffset = ed2.doc.blocks[0].text.len;
+ ed2.selAnchorBlock = ed2.cursorBlock; ed2.selAnchorOffset = ed2.cursorOffset;
+ editor_delete_forward(&ed2);
+ CHECK(ed2.doc.count == 3 && ed2.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL,
+ "Delete at end of a plain block immediately before a table is also a no-op -- doesn't swallow the table's first cell");
+ editor_free(&ed2);
+}
+
+static void test_table_cross_cell_selection_delete(void) {
+ EditorState ed;
+ editor_init(&ed);
+ editor_insert_table(&ed, 2, 2);
+ type_str(&ed, "aa"); /* cell 0 */
+ ed.cursorBlock = 1; ed.cursorOffset = 0; ed.selAnchorBlock = 1; ed.selAnchorOffset = 0;
+ type_str(&ed, "bb"); /* cell 1 */
+ ed.cursorBlock = 2; ed.cursorOffset = 0; ed.selAnchorBlock = 2; ed.selAnchorOffset = 0;
+ type_str(&ed, "cc"); /* cell 2 */
+ ed.cursorBlock = 3; ed.cursorOffset = 0; ed.selAnchorBlock = 3; ed.selAnchorOffset = 0;
+ type_str(&ed, "dd"); /* cell 3 */
+
+ /* Select from mid-cell-1 to mid-cell-2 (spans a row boundary) and delete. */
+ ed.selAnchorBlock = 1; ed.selAnchorOffset = 1;
+ ed.cursorBlock = 2; ed.cursorOffset = 1;
+ editor_backspace(&ed);
+
+ CHECK(ed.doc.count == 4, "the table's block count is unchanged -- no cell was removed");
+ CHECK(ed.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL && ed.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL
+ && ed.doc.blocks[2].type == BLOCK_TABLE_CELL && ed.doc.blocks[3].type == BLOCK_TABLE_CELL,
+ "every cell keeps its own type");
+ CHECK(text_eq(&ed.doc.blocks[0], "aa"), "cell 0, outside the selection, is untouched");
+ CHECK(text_eq(&ed.doc.blocks[1], "b"), "cell 1's own covered range is cleared in place, not merged elsewhere");
+ CHECK(text_eq(&ed.doc.blocks[2], "c"), "cell 2's own covered range is cleared in place");
+ CHECK(text_eq(&ed.doc.blocks[3], "dd"), "cell 3, outside the selection, is untouched");
+ CHECK(ed.cursorBlock == 1 && ed.cursorOffset == 1, "cursor lands at the selection start");
+ editor_free(&ed);
+}
+
+static void test_table_undo_redo(void) {
+ EditorState ed;
+ editor_init(&ed);
+ type_str(&ed, "before");
+ editor_insert_table(&ed, 1, 2);
+ CHECK(ed.doc.count == 2, "table inserted, replacing the '@word' block ('before' stands in for it here)");
+ CHECK(editor_undo(&ed), "undo succeeds");
+ CHECK(ed.doc.count == 1 && ed.doc.blocks[0].type == BLOCK_PARAGRAPH && text_eq(&ed.doc.blocks[0], "before"),
+ "undo removes the table and restores the original paragraph, text intact");
+ CHECK(editor_redo(&ed), "redo succeeds");
+ CHECK(ed.doc.count == 2 && ed.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL, "redo replays the table insertion");
+ editor_free(&ed);
+}
+
static void test_task_list(void) {
EditorState ed;
editor_init(&ed);
@@ -979,6 +1253,7 @@ int main(void) {
test_numbered_list_renumbers_on_save();
test_backspace_demotes_then_merges();
test_code_fence();
+ test_code_fence_double_enter_exit();
test_utf8_backspace();
test_inline_parse();
test_save_load_roundtrip();
@@ -1004,6 +1279,19 @@ int main(void) {
test_image_markdown();
test_image_enter_does_not_split_url();
test_image_resolve_path();
+ test_table_parse_basic();
+ test_table_alignment_parsing();
+ test_table_delimiter_disambiguation();
+ test_table_ragged_rows();
+ test_table_bullet_priority();
+ test_table_serialize_roundtrip();
+ test_table_insert();
+ test_table_tab_navigation();
+ test_table_enter_moves_like_tab();
+ test_table_backspace_guards();
+ test_table_delete_forward_guards();
+ test_table_cross_cell_selection_delete();
+ test_table_undo_redo();
test_task_list();
test_task_list_load_variants();
test_code_fence_with_language();
diff --git a/src/undo.h b/src/undo.h
index 9bd7ef6..b487748 100644
--- a/src/undo.h
+++ b/src/undo.h
@@ -24,7 +24,7 @@ void snapshot_stack_push(SnapshotStack *s, const Document *doc, int cursorBlock,
bool snapshot_stack_pop(SnapshotStack *s, Snapshot *out);
typedef enum { EDIT_NONE, EDIT_TYPE, EDIT_BACKSPACE, EDIT_DELETE_FWD, EDIT_ENTER, EDIT_PASTE, EDIT_TOGGLE_TASK,
- EDIT_QUICK_INSERT, EDIT_TURN_INTO } EditKind;
+ EDIT_QUICK_INSERT, EDIT_TURN_INTO, EDIT_INSERT_TABLE } EditKind;
typedef struct {
SnapshotStack undo, redo;