foxygit / Hush Log in
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,          /* "![alt](url)"; 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;