foxygit / Hush Log in
commit 54077b65de370ebdd90bcb89befc111bd1e3acef
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Mon Aug 24 20:11:27 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Mon Aug 24 20:11:27 2026 +0200

    Expand markdown syntax: headings H4-H6, HR, strikethrough, highlight,
    sub/superscript, task lists, code fence languages, autolinking

    Adds the parts of the markdownguide.org cheat sheet that fit hush's
    existing block/inline-run model without a new rendering paradigm:

    - Heading levels 4-6 (autoformat, load/save, styled progressively
      smaller, H6 muted)
    - Horizontal rules (---/***/___)
    - ~~strikethrough~~, ==highlight==, and approximated H~2~O/X^2^
      sub/superscript (smaller text, no true baseline shift)
    - Task lists (- [ ]/- [x]) with a real checkbox glyph, click-to-toggle
      (undo-wrapped), and muted+struck styling when checked
    - Fenced code blocks with a language tag (```lang), stored on Block
      and shown as a small label
    - Bare https://... URLs autolinked with no [text](url) needed

    Tables, real images, footnotes, definition lists, heading IDs, and
    emoji shortcodes are deliberately deferred -- each needs a genuinely
    new subsystem (grid layout, texture loading, cross-document reference
    collection) rather than a new block/run type. Documented in TODO.md.

    106 -> 132 passing assertions in hush_test; every new syntax element
    verified live in the GUI (rendering + click-to-toggle + its undo).

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 README.md          |  27 ++++++++-
 TODO.md            |  45 ++++++++++++++
 src/document.c     |   5 ++
 src/document.h     |  10 +++-
 src/editor.c       |  53 ++++++++++++++--
 src/editor.h       |   4 ++
 src/fonts.c        |   2 +
 src/inline_parse.c |  65 ++++++++++++++++++++
 src/inline_parse.h |   6 +-
 src/main.c         |   4 +-
 src/markdown_io.c  |  79 +++++++++++++++++++++---
 src/render.c       |  98 +++++++++++++++++++++++++++---
 src/render.h       |   6 +-
 src/test_main.c    | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/theme.h        |  10 ++++
 src/undo.h         |   2 +-
 16 files changed, 557 insertions(+), 32 deletions(-)

diff --git a/README.md b/README.md
index 05866d4..8fcdb6f 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@

 A small WYSIWYG markdown editor, built in C with [Clay](https://github.com/nicbarker/clay) and [raylib](https://www.raylib.com/).

-Markdown syntax you type (`# `, `**bold**`, `` `code` ``, `- `, `1. `, `> `, `` ``` ``, `[text](url)`) is rendered as styled text as you go, rather than shown as raw markup — the underlying markup only reappears around a span while your cursor is inside it.
+Markdown syntax you type is rendered as styled text as you go, rather than shown as raw markup — the underlying markup only reappears around a span while your cursor is inside it. See [Markdown syntax](#markdown-syntax) below for exactly what's supported.

 ## Features

-- Headings, bold, italic, inline code, links, block quotes, code blocks, bullet and numbered lists
+- Headings (H1–H6), bold, italic, strikethrough, highlight, subscript/superscript, inline code, links (including bare URLs), block quotes, horizontal rules, task lists, fenced code blocks (with a language tag), bullet and numbered lists
 - Text selection (Shift+arrows or click-drag) with copy/paste
 - Undo/redo
 - A status bar showing the document name, size, and line count
@@ -68,6 +68,29 @@ cmake --install build --prefix ~/.local

 The window's close button goes through the same unsaved-changes prompt as Ctrl+X.

+## Markdown syntax
+
+| Syntax | Result |
+|---|---|
+| `# ` … `###### ` | Heading 1–6 |
+| `**bold**` | **bold** |
+| `*italic*` | *italic* |
+| `~~strikethrough~~` | ~~strikethrough~~ |
+| `==highlight==` | highlighted text |
+| `H~2~O` | subscript |
+| `X^2^` | superscript |
+| `` `code` `` | inline code |
+| `` ``` `` or `` ```lang `` | fenced code block, optionally with a language tag |
+| `- ` or `* ` | Bullet list |
+| `1. ` | Numbered list (renumbered automatically on save) |
+| `- [ ] ` / `- [x] ` | Task list (click the checkbox to toggle) |
+| `> ` | Blockquote |
+| `---` (on its own line) | Horizontal rule |
+| `[text](url)` | Link |
+| a bare `https://…` or `http://…` | Autolink |
+
+A block's own markdown (e.g. the `**`/`*`/`#` markers) is only shown while your cursor is inside it; otherwise you see the styled result. Not on this list is deliberately out of scope for now — see [TODO.md](TODO.md) for what's missing (tables, images, footnotes, ...) and why.
+
 ## Configuration

 On first run, Hush creates a config file at `$XDG_CONFIG_HOME/hush/config` (or `~/.config/hush/config` if that variable isn't set), with every available key present but commented out. Uncomment and edit a line to change it; unknown or malformed lines are ignored.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..789a4dc
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,45 @@
+# TODO
+
+Markdown syntax deliberately left unimplemented, from the [Markdown Guide cheat
+sheet](https://www.markdownguide.org/cheat-sheet/). Everything else on that page (headings
+H1–H6, bold/italic, blockquotes, ordered/unordered lists, inline code, horizontal rules, links,
+fenced code blocks with a language tag, strikethrough, task lists, highlight, subscript,
+superscript, and automatic URL linking) is implemented.
+
+The items below don't fit hush's current architecture (a flat list of blocks, each holding one
+line of text, styled via inline "runs" over that text) without a genuinely new subsystem, not
+just a new block/run type. Rough notes on what each would take:
+
+- **Tables.** The document model is one line of text per block; a table is a 2D grid of cells,
+  each with its own inline-formatted content. Needs either a new block type whose `text` holds
+  the whole raw table (parsed and laid out as a grid at render time) or a run of dedicated
+  row/cell blocks — plus actual column-width computation and a grid layout in `render.c` (no
+  precedent for multi-column layout there today).
+
+- **Real images.** `![alt](url)` is currently no different from a plain paragraph line (falls
+  through markdown_io's parser as literal text) — there's no image loading/caching/rendering
+  path anywhere in the codebase. Needs texture loading from disk (raylib supports it, but
+  nothing here does it yet), cache invalidation when a document is closed, and a render path
+  that isn't just measured text (images don't word-wrap).
+
+- **Footnotes.** `[^1]` inline + `[^1]: text` definitions. Semantically these aren't inline
+  document order — a footnote definition is meant to be collected and rendered as an endnote,
+  which means a document-wide pass to gather definitions before rendering, not just a new
+  block/run type. The inline marker also needs to visually link to its endnote (superscript
+  number), which layers on top of the superscript work already done here.
+
+- **Definition lists.** `Term\n: Definition` — the current model has no notion of "this block is
+  associated with the previous one" (unlike numbered-list renumbering, which only counts
+  consecutive same-type blocks, it doesn't pair distinct types together). Would need a new
+  paired-block concept from scratch.
+
+- **Heading IDs.** `### Heading {#custom-id}` — parseable cheaply (strip a trailing `{#...}`
+  from the heading's text on load), but pointless without something to link *to* an ID —
+  hush has no internal anchor-link/navigation feature at all, so this only becomes worth adding
+  alongside real link-following.
+
+- **Emoji shortcodes.** `:joy:` → 😀. Needs a shortcode→codepoint lookup table (not hard) *and*
+  a font with real emoji coverage — the bundled DejaVu fonts only load ASCII + Latin-1 + a
+  handful of individually-added symbol codepoints (see `fonts.c`), nowhere near emoji range.
+  Would mean bundling a color/monochrome emoji font and wiring a second font source into the
+  glyph-loading path.
diff --git a/src/document.c b/src/document.c
index ef7f308..0ccdcd4 100644
--- a/src/document.c
+++ b/src/document.c
@@ -20,6 +20,7 @@ void document_init(Document *doc) {
 void document_free(Document *doc) {
     for (int i = 0; i < doc->count; i++) {
         sb_free(&doc->blocks[i].text);
+        sb_free(&doc->blocks[i].lang);
     }
     free(doc->blocks);
     doc->blocks = NULL;
@@ -35,6 +36,7 @@ void document_insert_block(Document *doc, int index, BlockType type, const char
     b->type = type;
     sb_init(&b->text);
     sb_append(&b->text, text, len);
+    sb_init(&b->lang);
 }

 void document_clone(Document *dst, const Document *src) {
@@ -45,11 +47,14 @@ void document_clone(Document *dst, const Document *src) {
         dst->blocks[i].type = src->blocks[i].type;
         sb_init(&dst->blocks[i].text);
         sb_append(&dst->blocks[i].text, src->blocks[i].text.data, src->blocks[i].text.len);
+        sb_init(&dst->blocks[i].lang);
+        sb_append(&dst->blocks[i].lang, src->blocks[i].lang.data, src->blocks[i].lang.len);
     }
 }

 void document_remove_block(Document *doc, int index) {
     sb_free(&doc->blocks[index].text);
+    sb_free(&doc->blocks[index].lang);
     memmove(&doc->blocks[index], &doc->blocks[index + 1], (size_t)(doc->count - index - 1) * sizeof(Block));
     doc->count--;
 }
diff --git a/src/document.h b/src/document.h
index 34b76ea..f8ccfb1 100644
--- a/src/document.h
+++ b/src/document.h
@@ -8,15 +8,22 @@ typedef enum {
     BLOCK_H1,
     BLOCK_H2,
     BLOCK_H3,
+    BLOCK_H4,
+    BLOCK_H5,
+    BLOCK_H6,
     BLOCK_BULLET,
     BLOCK_NUMBERED,
     BLOCK_QUOTE,
     BLOCK_CODE,
+    BLOCK_HR,             /* horizontal rule; text/lang are always empty, never edited */
+    BLOCK_TASK_UNCHECKED, /* "- [ ] text" */
+    BLOCK_TASK_CHECKED,   /* "- [x] text" */
 } BlockType;

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

 typedef struct {
@@ -27,7 +34,8 @@ typedef struct {

 /* True for block types that "continue" themselves on Enter (list-like blocks). */
 static inline int block_type_is_continuable(BlockType t) {
-    return t == BLOCK_BULLET || t == BLOCK_NUMBERED || t == BLOCK_QUOTE;
+    return t == BLOCK_BULLET || t == BLOCK_NUMBERED || t == BLOCK_QUOTE
+        || t == BLOCK_TASK_UNCHECKED || t == BLOCK_TASK_CHECKED;
 }

 void document_init(Document *doc);
diff --git a/src/editor.c b/src/editor.c
index 50a6ce5..70ea916 100644
--- a/src/editor.c
+++ b/src/editor.c
@@ -72,16 +72,31 @@ const char *editor_display_name(const EditorState *ed) {
    space that was just inserted into the corresponding block type, WYSIWYG-style. */
 static void maybe_autoformat_block(EditorState *ed) {
     Block *b = &ed->doc.blocks[ed->cursorBlock];
-    if (b->type != BLOCK_PARAGRAPH) return;
-
     int upto = ed->cursorOffset - 1; /* text before the space just typed */
     if (upto <= 0) return;
     const char *t = b->text.data;

+    if (b->type == BLOCK_BULLET) {
+        /* "- " already turned this block into a bullet; typing "[ ] " or "[x] " right after,
+           at the very start of the item's own text, turns it into a task-list item. */
+        if (upto == 3 && t[0] == '[' && (t[1] == ' ' || t[1] == 'x' || t[1] == 'X') && t[2] == ']') {
+            BlockType newType = (t[1] == ' ') ? BLOCK_TASK_UNCHECKED : BLOCK_TASK_CHECKED;
+            sb_delete(&b->text, 0, ed->cursorOffset);
+            ed->cursorOffset = 0;
+            b->type = newType;
+        }
+        return;
+    }
+
+    if (b->type != BLOCK_PARAGRAPH) return;
+
     BlockType newType;
-    if (upto == 1 && t[0] == '#') newType = BLOCK_H1;
-    else if (upto == 2 && t[0] == '#' && t[1] == '#') newType = BLOCK_H2;
-    else if (upto == 3 && t[0] == '#' && t[1] == '#' && t[2] == '#') newType = BLOCK_H3;
+    int hashes = 0;
+    while (hashes < upto && hashes < 6 && t[hashes] == '#') hashes++;
+    if (hashes > 0 && hashes == upto) {
+        static const BlockType headingTypes[6] = { BLOCK_H1, BLOCK_H2, BLOCK_H3, BLOCK_H4, BLOCK_H5, BLOCK_H6 };
+        newType = headingTypes[hashes - 1];
+    }
     else if (upto == 1 && (t[0] == '-' || t[0] == '*')) newType = BLOCK_BULLET;
     else if (upto == 1 && t[0] == '>') newType = BLOCK_QUOTE;
     else if (upto >= 2 && isdigit((unsigned char)t[0])) {
@@ -288,7 +303,8 @@ static void enter_raw(EditorState *ed) {
         return;
     }

-    if (b->type == BLOCK_PARAGRAPH && b->text.len == 3 && memcmp(b->text.data, "```", 3) == 0) {
+    if (b->type == BLOCK_PARAGRAPH && b->text.len >= 3 && memcmp(b->text.data, "```", 3) == 0) {
+        sb_append(&b->lang, b->text.data + 3, b->text.len - 3);
         b->type = BLOCK_CODE;
         sb_clear(&b->text);
         ed->cursorOffset = 0;
@@ -297,6 +313,18 @@ static void enter_raw(EditorState *ed) {
         return;
     }

+    if (b->type == BLOCK_PARAGRAPH && b->text.len == 3
+        && (memcmp(b->text.data, "---", 3) == 0 || memcmp(b->text.data, "***", 3) == 0 || memcmp(b->text.data, "___", 3) == 0)) {
+        b->type = BLOCK_HR;
+        sb_clear(&b->text);
+        document_insert_block(&ed->doc, ed->cursorBlock + 1, BLOCK_PARAGRAPH, "", 0);
+        ed->cursorBlock++;
+        ed->cursorOffset = 0;
+        ed->dirty = true;
+        reset_preferred_x(ed);
+        return;
+    }
+
     if (block_type_is_continuable(b->type) && b->text.len == 0) {
         b->type = BLOCK_PARAGRAPH;
         ed->dirty = true;
@@ -307,6 +335,7 @@ static void enter_raw(EditorState *ed) {
     int splitAt = ed->cursorOffset;
     int tailLen = b->text.len - splitAt;
     BlockType newType = block_type_is_continuable(b->type) ? b->type : BLOCK_PARAGRAPH;
+    if (newType == BLOCK_TASK_CHECKED) newType = BLOCK_TASK_UNCHECKED; /* a freshly split task starts unchecked */

     document_insert_block(&ed->doc, ed->cursorBlock + 1, newType, b->text.data + splitAt, tailLen);
     b = &ed->doc.blocks[ed->cursorBlock]; /* doc.blocks may have been reallocated */
@@ -426,3 +455,15 @@ bool editor_redo(EditorState *ed) {
     reset_preferred_x(ed);
     return true;
 }
+
+bool editor_toggle_task(EditorState *ed, int blockIndex) {
+    if (blockIndex < 0 || blockIndex >= ed->doc.count) return false;
+    Block *b = &ed->doc.blocks[blockIndex];
+    if (b->type != BLOCK_TASK_UNCHECKED && b->type != BLOCK_TASK_CHECKED) return false;
+
+    undo_manager_before_edit(&ed->undo, EDIT_TOGGLE_TASK, &ed->doc, ed->cursorBlock, ed->cursorOffset);
+    b->type = (b->type == BLOCK_TASK_UNCHECKED) ? BLOCK_TASK_CHECKED : BLOCK_TASK_UNCHECKED;
+    ed->dirty = true;
+    undo_manager_after_edit(&ed->undo, EDIT_TOGGLE_TASK, ed->cursorBlock, ed->cursorOffset);
+    return true;
+}
diff --git a/src/editor.h b/src/editor.h
index e4eac2e..a073bb5 100644
--- a/src/editor.h
+++ b/src/editor.h
@@ -38,6 +38,10 @@ void editor_enter(EditorState *ed);
 bool editor_undo(EditorState *ed);
 bool editor_redo(EditorState *ed);

+/* Flips a task-list block between checked/unchecked (undo-wrapped). Returns false if
+   `blockIndex` is out of range or isn't a task block. */
+bool editor_toggle_task(EditorState *ed, int blockIndex);
+
 /* `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/fonts.c b/src/fonts.c
index 43aecc7..55ee51f 100644
--- a/src/fonts.c
+++ b/src/fonts.c
@@ -12,6 +12,8 @@ static int *build_codepoints(int *count) {
     static const int extra[] = {
         0x2022, /* bullet, used for BLOCK_BULLET markers */
         0x2014, /* em dash, used in the status bar and modal UI text */
+        0x2610, /* ballot box, unchecked task-list marker */
+        0x2611, /* ballot box with check, checked task-list marker */
     };
     int extraCount = (int)(sizeof(extra) / sizeof(extra[0]));
     int n = latin1 + extraCount;
diff --git a/src/inline_parse.c b/src/inline_parse.c
index 1d30b11..8b87c5e 100644
--- a/src/inline_parse.c
+++ b/src/inline_parse.c
@@ -1,5 +1,7 @@
 #include "inline_parse.h"
 #include <stdlib.h>
+#include <string.h>
+#include <ctype.h>

 void inline_runs_init(InlineRunList *list) {
     list->runs = NULL;
@@ -45,6 +47,23 @@ static int find_double(const char *text, int len, int from, char c) {
     return -1;
 }

+/* If text[i..] starts with "http://" or "https://", returns the length of the URL run
+   (stopping at whitespace/'<'/'>', then trimming trailing punctuation that's usually not
+   meant to be part of the URL, e.g. the '.' ending a sentence). Returns 0 if no match. */
+static int match_autolink_len(const char *text, int i, int len) {
+    static const char *const schemes[] = { "https://", "http://" };
+    for (int s = 0; s < 2; s++) {
+        int slen = (int)strlen(schemes[s]);
+        if (i + slen <= len && memcmp(text + i, schemes[s], (size_t)slen) == 0) {
+            int j = i + slen;
+            while (j < len && !isspace((unsigned char)text[j]) && text[j] != '<' && text[j] != '>') j++;
+            while (j > i + slen && strchr(".,;:!?)", text[j - 1])) j--;
+            return j - i;
+        }
+    }
+    return 0;
+}
+
 void inline_parse(const char *text, int len, InlineRunList *out) {
     inline_runs_init(out);
     int i = 0;
@@ -105,6 +124,52 @@ void inline_parse(const char *text, int len, InlineRunList *out) {
                     matched = 1;
                 }
             }
+        } else if (c == '~' && i + 1 < len && text[i + 1] == '~') {
+            int j = find_double(text, len, i + 2, '~');
+            if (j >= 0 && j > i + 2) {
+                if (i > plainStart) push_run(out, RUN_PLAIN, plainStart, i, plainStart, i);
+                push_run(out, RUN_STRIKE, i, j + 2, i + 2, j);
+                i = j + 2;
+                plainStart = i;
+                matched = 1;
+            }
+        } else if (c == '~') {
+            int j = find_char(text, len, i + 1, '~');
+            if (j >= 0 && j > i + 1) {
+                if (i > plainStart) push_run(out, RUN_PLAIN, plainStart, i, plainStart, i);
+                push_run(out, RUN_SUB, i, j + 1, i + 1, j);
+                i = j + 1;
+                plainStart = i;
+                matched = 1;
+            }
+        } else if (c == '=' && i + 1 < len && text[i + 1] == '=') {
+            int j = find_double(text, len, i + 2, '=');
+            if (j >= 0 && j > i + 2) {
+                if (i > plainStart) push_run(out, RUN_PLAIN, plainStart, i, plainStart, i);
+                push_run(out, RUN_HIGHLIGHT, i, j + 2, i + 2, j);
+                i = j + 2;
+                plainStart = i;
+                matched = 1;
+            }
+        } else if (c == '^') {
+            int j = find_char(text, len, i + 1, '^');
+            if (j >= 0 && j > i + 1) {
+                if (i > plainStart) push_run(out, RUN_PLAIN, plainStart, i, plainStart, i);
+                push_run(out, RUN_SUPER, i, j + 1, i + 1, j);
+                i = j + 1;
+                plainStart = i;
+                matched = 1;
+            }
+        } else if (c == 'h') {
+            int alen = match_autolink_len(text, i, len);
+            if (alen > 0) {
+                if (i > plainStart) push_run(out, RUN_PLAIN, plainStart, i, plainStart, i);
+                /* No markers to hide/reveal for a bare-URL autolink: raw range == content range. */
+                push_run(out, RUN_LINK, i, i + alen, i, i + alen);
+                i += alen;
+                plainStart = i;
+                matched = 1;
+            }
         } else if (c == '\\' && i + 1 < len) {
             i += 2;
             continue;
diff --git a/src/inline_parse.h b/src/inline_parse.h
index 5583059..38352c6 100644
--- a/src/inline_parse.h
+++ b/src/inline_parse.h
@@ -7,7 +7,11 @@ typedef enum {
     RUN_ITALIC,
     RUN_BOLD_ITALIC,
     RUN_CODE,
-    RUN_LINK,
+    RUN_LINK,      /* also used for bare-URL autolinks: rawStart==contentStart, rawEnd==contentEnd */
+    RUN_STRIKE,
+    RUN_HIGHLIGHT,
+    RUN_SUB,
+    RUN_SUPER,
 } RunKind;

 typedef struct {
diff --git a/src/main.c b/src/main.c
index 84f7091..96a14e8 100644
--- a/src/main.c
+++ b/src/main.c
@@ -167,7 +167,8 @@ int main(int argc, char **argv) {

         BeginDrawing();
         ClearBackground(BLACK);
-        ModalAction action = render_frame(&ed, &cache, fonts, caretBlinkT, mode);
+        int toggledTaskBlock;
+        ModalAction action = render_frame(&ed, &cache, fonts, caretBlinkT, mode, &toggledTaskBlock);
         EndDrawing();

         switch (action) {
@@ -177,6 +178,7 @@ int main(int argc, char **argv) {
             case MODAL_ACTION_CLOSE_HELP:   mode = MODE_EDITING; break;
             default: break;
         }
+        if (mode == MODE_EDITING && toggledTaskBlock >= 0) editor_toggle_task(&ed, toggledTaskBlock);
     }

     layout_cache_free(&cache);
diff --git a/src/markdown_io.c b/src/markdown_io.c
index 55d7ce4..aba932a 100644
--- a/src/markdown_io.c
+++ b/src/markdown_io.c
@@ -27,6 +27,50 @@ static int is_code_fence(const char *line, int len) {
     return len >= 3 && line[0] == '`' && line[1] == '`' && line[2] == '`';
 }

+/* A line consisting of 3+ of the same char among '-', '*', '_' (spaces allowed between them,
+   as in "- - -") and nothing else -- a horizontal rule. */
+static int is_hr_line(const char *line, int len) {
+    int n = 0;
+    char c = 0;
+    for (int i = 0; i < len; i++) {
+        if (line[i] == ' ' || line[i] == '\t') continue;
+        if (c == 0) {
+            c = line[i];
+            if (c != '-' && c != '*' && c != '_') return 0;
+            n++;
+        } else if (line[i] == c) {
+            n++;
+        } else {
+            return 0;
+        }
+    }
+    return n >= 3;
+}
+
+/* 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;
+    while (n < len && n < 7 && line[n] == '#') n++;
+    if (n == 0 || n > 6) return 0;
+    if (n >= len || line[n] != ' ') return 0;
+    *level = n;
+    *prefixLen = n + 1;
+    return 1;
+}
+
+/* Task-list item: "- [ ] " / "- [x] " / "- [X] " (checked). Must be checked before the plain
+   bullet prefix, which would otherwise match its first two bytes. */
+static int match_task_prefix(const char *line, int len, int *prefixLen, int *checked) {
+    if (len < 6) return 0;
+    if ((line[0] != '-' && line[0] != '*') || line[1] != ' ') return 0;
+    if (line[2] != '[' || line[4] != ']' || line[5] != ' ') return 0;
+    char mark = line[3];
+    if (mark != ' ' && mark != 'x' && mark != 'X') return 0;
+    *checked = (mark != ' ');
+    *prefixLen = 6;
+    return 1;
+}
+
 bool document_load_file(Document *doc, const char *path) {
     FILE *f = fopen(path, "rb");
     if (!f) return false;
@@ -41,7 +85,7 @@ bool document_load_file(Document *doc, const char *path) {
     fclose(f);
     buf[readN] = '\0';

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

     int pos = 0;
@@ -57,6 +101,10 @@ bool document_load_file(Document *doc, const char *path) {
         int lineLen = lineEnd - lineStart;

         if (is_code_fence(line, lineLen)) {
+            int langStart = 3, langEnd = lineLen;
+            while (langStart < langEnd && line[langStart] == ' ') langStart++;
+            while (langEnd > langStart && line[langEnd - 1] == ' ') langEnd--;
+
             StrBuf code;
             sb_init(&code);
             int first = 1;
@@ -73,18 +121,23 @@ bool document_load_file(Document *doc, const char *path) {
             }
             document_insert_block(doc, doc->count, BLOCK_CODE, code.data, code.len);
             sb_free(&code);
+            if (langEnd > langStart) sb_append(&doc->blocks[doc->count - 1].lang, line + langStart, langEnd - langStart);
+            continue;
+        }
+
+        if (is_hr_line(line, lineLen)) {
+            document_insert_block(doc, doc->count, BLOCK_HR, "", 0);
             continue;
         }

         if (is_blank_line(line, lineLen)) continue;

-        int prefixLen;
-        if (lineLen >= 2 && line[0] == '#' && line[1] == ' ') {
-            document_insert_block(doc, doc->count, BLOCK_H1, line + 2, lineLen - 2);
-        } else if (lineLen >= 3 && line[0] == '#' && line[1] == '#' && line[2] == ' ') {
-            document_insert_block(doc, doc->count, BLOCK_H2, line + 3, lineLen - 3);
-        } else if (lineLen >= 4 && line[0] == '#' && line[1] == '#' && line[2] == '#' && line[3] == ' ') {
-            document_insert_block(doc, doc->count, BLOCK_H3, line + 4, lineLen - 4);
+        int prefixLen, level, checked;
+        if (match_heading_prefix(line, lineLen, &level, &prefixLen)) {
+            static const BlockType headingTypes[6] = { BLOCK_H1, BLOCK_H2, BLOCK_H3, BLOCK_H4, BLOCK_H5, BLOCK_H6 };
+            document_insert_block(doc, doc->count, headingTypes[level - 1], line + prefixLen, lineLen - prefixLen);
+        } else if (match_task_prefix(line, lineLen, &prefixLen, &checked)) {
+            document_insert_block(doc, doc->count, checked ? BLOCK_TASK_CHECKED : BLOCK_TASK_UNCHECKED, line + prefixLen, lineLen - prefixLen);
         } else if (lineLen >= 2 && (line[0] == '-' || line[0] == '*') && line[1] == ' ') {
             document_insert_block(doc, doc->count, BLOCK_BULLET, line + 2, lineLen - 2);
         } else if (lineLen >= 2 && line[0] == '>' && line[1] == ' ') {
@@ -117,7 +170,12 @@ void document_serialize(Document *doc, StrBuf *out) {
             case BLOCK_H1: sb_append(out, "# ", 2); sb_append(out, b->text.data, b->text.len); break;
             case BLOCK_H2: sb_append(out, "## ", 3); sb_append(out, b->text.data, b->text.len); break;
             case BLOCK_H3: sb_append(out, "### ", 4); sb_append(out, b->text.data, b->text.len); break;
+            case BLOCK_H4: sb_append(out, "#### ", 5); sb_append(out, b->text.data, b->text.len); break;
+            case BLOCK_H5: sb_append(out, "##### ", 6); sb_append(out, b->text.data, b->text.len); break;
+            case BLOCK_H6: sb_append(out, "###### ", 7); sb_append(out, b->text.data, b->text.len); break;
             case BLOCK_BULLET: sb_append(out, "- ", 2); sb_append(out, b->text.data, b->text.len); break;
+            case BLOCK_TASK_UNCHECKED: sb_append(out, "- [ ] ", 6); sb_append(out, b->text.data, b->text.len); break;
+            case BLOCK_TASK_CHECKED: sb_append(out, "- [x] ", 6); sb_append(out, b->text.data, b->text.len); break;
             case BLOCK_NUMBERED: {
                 if (i > 0 && doc->blocks[i - 1].type == BLOCK_NUMBERED) numberedRun++;
                 else numberedRun = 1;
@@ -128,8 +186,11 @@ void document_serialize(Document *doc, StrBuf *out) {
                 break;
             }
             case BLOCK_QUOTE: sb_append(out, "> ", 2); sb_append(out, b->text.data, b->text.len); break;
+            case BLOCK_HR: sb_append(out, "---", 3); break;
             case BLOCK_CODE:
-                sb_append(out, "```\n", 4);
+                sb_append(out, "```", 3);
+                sb_append(out, b->lang.data, b->lang.len);
+                sb_append_char(out, '\n');
                 sb_append(out, b->text.data, b->text.len);
                 sb_append(out, "\n```", 4);
                 break;
diff --git a/src/render.c b/src/render.c
index e064da0..75e0355 100644
--- a/src/render.c
+++ b/src/render.c
@@ -12,7 +12,8 @@
 #include <stdio.h>
 #include <math.h>

-typedef enum { RK_PLAIN, RK_BOLD, RK_ITALIC, RK_BOLD_ITALIC, RK_CODE, RK_LINK, RK_MARKER } RenderKind;
+typedef enum { RK_PLAIN, RK_BOLD, RK_ITALIC, RK_BOLD_ITALIC, RK_CODE, RK_LINK, RK_MARKER,
+               RK_STRIKE, RK_HIGHLIGHT, RK_SUB, RK_SUPER } RenderKind;

 typedef struct {
     RenderKind kind;
@@ -25,8 +26,24 @@ typedef struct {
     float contentWidth;
     int cursorBlock;
     int cursorOffset;
+    int *toggledTaskBlock; /* set to a block index this frame if its task checkbox was clicked */
 } RenderCtx;

+/* Segments that need a strikethrough line drawn through them once their final screen position
+   is known (after Clay_EndLayout) -- either a RUN_STRIKE (~~x~~) span, or every segment of a
+   checked task-list item's whole line. Reset each frame. */
+#define MAX_STRIKE_MARKS 512
+typedef struct { int segIdx; Clay_Color color; } StrikeMark;
+static StrikeMark g_strikeMarks[MAX_STRIKE_MARKS];
+static int g_strikeMarkCount;
+
+static void mark_strike(int segIdx, Clay_Color color) {
+    if (g_strikeMarkCount >= MAX_STRIKE_MARKS) return;
+    g_strikeMarks[g_strikeMarkCount].segIdx = segIdx;
+    g_strikeMarks[g_strikeMarkCount].color = color;
+    g_strikeMarkCount++;
+}
+
 typedef struct {
     Font *fonts;
     RenderRun *runs;
@@ -57,6 +74,9 @@ static void resolve_style(RenderKind kind, bool boldBase, int baseFontSize, Clay
     if (kind == RK_MARKER) { mono = false; bold = false; italic = false; }
     *fontId = font_index(mono, bold, italic);
     *fontSize = baseFontSize;
+    /* Approximated as smaller text, vertically centered like every other segment on the line --
+       true baseline raise/lower isn't supported by the current per-line layout. */
+    if (kind == RK_SUB || kind == RK_SUPER) *fontSize = (baseFontSize * 7) / 10;
     switch (kind) {
         case RK_MARKER: *color = COL_MARKER; break;
         case RK_LINK:   *color = COL_LINK; break;
@@ -106,6 +126,10 @@ static void expand_runs(InlineRunList *runs, int cursorBlock, int cursorOffset,
         RenderKind styleKind = r->kind == RUN_BOLD ? RK_BOLD
                               : r->kind == RUN_ITALIC ? RK_ITALIC
                               : r->kind == RUN_BOLD_ITALIC ? RK_BOLD_ITALIC
+                              : r->kind == RUN_STRIKE ? RK_STRIKE
+                              : r->kind == RUN_HIGHLIGHT ? RK_HIGHLIGHT
+                              : r->kind == RUN_SUB ? RK_SUB
+                              : r->kind == RUN_SUPER ? RK_SUPER
                               : RK_LINK;
         bool focused = hasCursor && cursorOffset >= r->rawStart && cursorOffset <= r->rawEnd;
         if (!focused) {
@@ -129,7 +153,7 @@ 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) {
+                                float availWidth, int baseFontSize, bool boldBase, Clay_Color baseColor, bool forceStrike) {
     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; }

@@ -164,6 +188,8 @@ static void emit_wrapped_lines(RenderCtx *ctx, int blockIndex, const char *text,
                 Clay_String txt = { .isStaticallyAllocated = false, .length = ws->end - ws->start, .chars = text + ws->start };
                 Clay_TextElementConfig tc = { .fontId = (uint16_t)fontId, .fontSize = (uint16_t)fontSize, .textColor = color, .wrapMode = CLAY_TEXT_WRAP_NONE };

+                if (kind == RK_STRIKE || forceStrike) mark_strike(segIdx, color);
+
                 if (kind == RK_CODE) {
                     CLAY(CLAY_IDI("Seg", segIdx), {
                         .layout = { .padding = { 5, 5, 1, 1 } },
@@ -172,6 +198,14 @@ static void emit_wrapped_lines(RenderCtx *ctx, int blockIndex, const char *text,
                     }) {
                         CLAY_TEXT(txt, tc);
                     }
+                } else if (kind == RK_HIGHLIGHT) {
+                    CLAY(CLAY_IDI("Seg", segIdx), {
+                        .layout = { .padding = { 2, 2, 0, 0 } },
+                        .backgroundColor = COL_HIGHLIGHT_BG,
+                        .cornerRadius = CLAY_CORNER_RADIUS(2),
+                    }) {
+                        CLAY_TEXT(txt, tc);
+                    }
                 } else {
                     CLAY(CLAY_IDI("Seg", segIdx), {
                         .layout = { .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) } },
@@ -237,7 +271,12 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
         case BLOCK_H1: baseFontSize = FS_H1; boldBase = true; topPad = 22; break;
         case BLOCK_H2: baseFontSize = FS_H2; boldBase = true; topPad = 16; break;
         case BLOCK_H3: baseFontSize = FS_H3; boldBase = true; topPad = 10; break;
+        case BLOCK_H4: baseFontSize = FS_H4; boldBase = true; topPad = 8; break;
+        case BLOCK_H5: baseFontSize = FS_H5; boldBase = true; topPad = 6; break;
+        case BLOCK_H6: baseFontSize = FS_H6; boldBase = true; topPad = 6; baseColor = COL_H6_TEXT; break;
         case BLOCK_QUOTE: baseColor = COL_TEXT_QUOTE; break;
+        case BLOCK_TASK_CHECKED: baseColor = COL_TEXT_QUOTE; break;
+        case BLOCK_HR: topPad = HR_TOP_PAD; break;
         default: break;
     }

@@ -265,7 +304,23 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
     CLAY(CLAY_IDI("Block", blockIndex), decl) {
         if (b->type == BLOCK_CODE) {
             emit_code_block(ctx, blockIndex, b->text.data, b->text.len);
-        } else if (b->type == BLOCK_BULLET || b->type == BLOCK_NUMBERED) {
+            if (b->lang.len > 0) {
+                Clay_String langText = { .isStaticallyAllocated = false, .length = b->lang.len, .chars = b->lang.data };
+                CLAY_AUTO_ID({
+                    .layout = { .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) }, .childAlignment = { .x = CLAY_ALIGN_X_RIGHT } },
+                }) {
+                    CLAY_TEXT(langText, CLAY_TEXT_CONFIG({ .fontId = FONT_MONO_REGULAR, .fontSize = FS_CODE_LANG, .textColor = COL_MARKER }));
+                }
+            }
+        } else if (b->type == BLOCK_HR) {
+            CLAY_AUTO_ID({
+                .layout = { .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(HR_THICKNESS) } },
+                .backgroundColor = COL_HR,
+            }) {}
+        } else if (b->type == BLOCK_BULLET || b->type == BLOCK_NUMBERED
+                   || b->type == BLOCK_TASK_UNCHECKED || b->type == BLOCK_TASK_CHECKED) {
+            bool isTask = (b->type == BLOCK_TASK_UNCHECKED || b->type == BLOCK_TASK_CHECKED);
+
             InlineRunList runs;
             inline_parse(b->text.data, b->text.len, &runs);
             RenderRun *rr; int rc;
@@ -275,24 +330,35 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
             CLAY_AUTO_ID({
                 .layout = { .layoutDirection = CLAY_LEFT_TO_RIGHT, .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) } },
             }) {
-                CLAY_AUTO_ID({
+                Clay_ElementId markerId = CLAY_IDI("Marker", blockIndex);
+                bool markerHovered = isTask && Clay_PointerOver(markerId);
+                CLAY(markerId, {
                     .layout = { .sizing = { CLAY_SIZING_FIXED(LIST_INDENT), CLAY_SIZING_FIT(0) } },
                 }) {
                     Clay_String markerText;
                     if (b->type == BLOCK_BULLET) {
                         markerText = CLAY_STRING("\xE2\x80\xA2");
+                    } else if (b->type == BLOCK_TASK_UNCHECKED) {
+                        markerText = CLAY_STRING("\xE2\x98\x90");
+                    } else if (b->type == BLOCK_TASK_CHECKED) {
+                        markerText = CLAY_STRING("\xE2\x98\x91");
                     } else {
                         char *buf = scratch_alloc(16);
                         int num = compute_list_number(doc, blockIndex);
                         int n = snprintf(buf, 16, "%d.", num);
                         markerText = (Clay_String){ .isStaticallyAllocated = false, .length = n, .chars = buf };
                     }
-                    CLAY_TEXT(markerText, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = (uint16_t)baseFontSize, .textColor = g_theme.text }));
+                    CLAY_TEXT(markerText, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = (uint16_t)baseFontSize,
+                                                               .textColor = markerHovered ? COL_LINK : g_theme.text }));
+                }
+                if (markerHovered && IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && ctx->toggledTaskBlock) {
+                    *ctx->toggledTaskBlock = blockIndex;
                 }
                 CLAY_AUTO_ID({
                     .layout = { .layoutDirection = CLAY_TOP_TO_BOTTOM, .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) } },
                 }) {
-                    emit_wrapped_lines(ctx, blockIndex, b->text.data, rr, rc, ctx->contentWidth - LIST_INDENT, baseFontSize, boldBase, baseColor);
+                    emit_wrapped_lines(ctx, blockIndex, b->text.data, rr, rc, ctx->contentWidth - LIST_INDENT,
+                                        baseFontSize, boldBase, baseColor, b->type == BLOCK_TASK_CHECKED);
                 }
             }
             free(rr);
@@ -304,7 +370,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);
+            emit_wrapped_lines(ctx, blockIndex, b->text.data, rr, rc, avail, baseFontSize, boldBase, baseColor, false);
             free(rr);
         }
     }
@@ -410,8 +476,9 @@ Clay_Dimensions hush_measure_text(Clay_StringSlice text, Clay_TextElementConfig
     return (Clay_Dimensions){ sz.x, sz.y > 0 ? sz.y : (float)config->fontSize };
 }

-ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float caretBlinkT, AppMode mode) {
+ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float caretBlinkT, AppMode mode, int *outToggledTaskBlock) {
     g_scratchPos = 0;
+    g_strikeMarkCount = 0;
     layout_cache_reset(cache);

     float winW = (float)GetScreenWidth();
@@ -420,7 +487,8 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float
     if (contentWidth > CONTENT_MAX_WIDTH) contentWidth = CONTENT_MAX_WIDTH;
     if (contentWidth < 100.0f) contentWidth = 100.0f;

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

     Clay_BeginLayout();

@@ -476,6 +544,17 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float
     Clay_RenderCommandArray cmds = Clay_EndLayout(GetFrameTime());
     Clay_Raylib_Render(cmds, fonts);

+    for (int i = 0; i < g_strikeMarkCount; i++) {
+        Clay_ElementData sd = Clay_GetElementData(CLAY_IDI("Seg", g_strikeMarks[i].segIdx));
+        if (!sd.found) continue;
+        Color lineColor = {
+            (unsigned char)roundf(g_strikeMarks[i].color.r), (unsigned char)roundf(g_strikeMarks[i].color.g),
+            (unsigned char)roundf(g_strikeMarks[i].color.b), (unsigned char)roundf(g_strikeMarks[i].color.a),
+        };
+        int midY = (int)(sd.boundingBox.y + sd.boundingBox.height * 0.5f);
+        DrawRectangle((int)sd.boundingBox.x, midY, (int)sd.boundingBox.width, 1, lineColor);
+    }
+
     if (mode == MODE_EDITING && editor_has_selection(ed)) {
         int sb, so, eb, eo;
         editor_selection_range(ed, &sb, &so, &eb, &eo);
@@ -508,5 +587,6 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float
         }
     }

+    if (outToggledTaskBlock) *outToggledTaskBlock = toggledTaskBlock;
     return modalAction;
 }
diff --git a/src/render.h b/src/render.h
index ef2a958..8d6e131 100644
--- a/src/render.h
+++ b/src/render.h
@@ -23,7 +23,9 @@ Clay_Dimensions hush_measure_text(Clay_StringSlice text, Clay_TextElementConfig
    blinking caret on top. Call between BeginDrawing()/EndDrawing(), after
    Clay_SetPointerState/Clay_SetLayoutDimensions/Clay_UpdateScrollContainers.
    When `mode` is not MODE_EDITING, also draws the corresponding modal on top and returns
-   whichever action the user's click resolved to this frame (MODAL_ACTION_NONE otherwise). */
-ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float caretBlinkT, AppMode mode);
+   whichever action the user's click resolved to this frame (MODAL_ACTION_NONE otherwise).
+   `*outToggledTaskBlock` is set to the block index of a task-list checkbox clicked this frame,
+   or -1 if none (ignored if NULL) -- caller should follow up with editor_toggle_task. */
+ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float caretBlinkT, AppMode mode, int *outToggledTaskBlock);

 #endif
diff --git a/src/test_main.c b/src/test_main.c
index e39f806..f236fda 100644
--- a/src/test_main.c
+++ b/src/test_main.c
@@ -434,6 +434,171 @@ static void test_lang_parse_line(void) {
     CHECK(strcmp(lang_get(STR_HELP_CLOSE), "Close") == 0, "lang_free() clears overrides back to the English defaults");
 }

+static void test_heading_levels_4_5_6(void) {
+    EditorState ed;
+    editor_init(&ed);
+    type_str(&ed, "#### ");
+    CHECK(ed.doc.blocks[0].type == BLOCK_H4, "typing '#### ' converts block to H4");
+    CHECK(text_eq(&ed.doc.blocks[0], ""), "H4 marker text is stripped");
+
+    editor_enter(&ed);
+    type_str(&ed, "##### ");
+    CHECK(ed.doc.blocks[1].type == BLOCK_H5, "typing '##### ' converts block to H5");
+
+    editor_enter(&ed);
+    type_str(&ed, "###### ");
+    CHECK(ed.doc.blocks[2].type == BLOCK_H6, "typing '###### ' converts block to H6");
+
+    editor_enter(&ed);
+    type_str(&ed, "####### ");
+    CHECK(ed.doc.blocks[3].type == BLOCK_PARAGRAPH, "7 '#'s does not autoformat (not a valid heading level)");
+    CHECK(text_eq(&ed.doc.blocks[3], "####### "), "7 '#'s stays literal paragraph text");
+    editor_free(&ed);
+}
+
+static void test_heading_levels_round_trip(void) {
+    EditorState ed;
+    editor_init(&ed);
+    type_str(&ed, "#### Four");
+    editor_enter(&ed);
+    type_str(&ed, "##### Five");
+    editor_enter(&ed);
+    type_str(&ed, "###### Six");
+
+    CHECK(document_save_file(&ed.doc, "test_headings456.md"), "save succeeds");
+    EditorState ed2;
+    editor_init(&ed2);
+    CHECK(editor_load(&ed2, "test_headings456.md"), "load succeeds");
+    CHECK(ed2.doc.count == 3, "three heading blocks round-trip");
+    CHECK(ed2.doc.blocks[0].type == BLOCK_H4 && text_eq(&ed2.doc.blocks[0], "Four"), "H4 round-trips");
+    CHECK(ed2.doc.blocks[1].type == BLOCK_H5 && text_eq(&ed2.doc.blocks[1], "Five"), "H5 round-trips");
+    CHECK(ed2.doc.blocks[2].type == BLOCK_H6 && text_eq(&ed2.doc.blocks[2], "Six"), "H6 round-trips");
+    editor_free(&ed);
+    editor_free(&ed2);
+}
+
+static void test_horizontal_rule(void) {
+    EditorState ed;
+    editor_init(&ed);
+    type_str(&ed, "---");
+    editor_enter(&ed);
+    CHECK(ed.doc.count == 2, "Enter after '---' creates a second block");
+    CHECK(ed.doc.blocks[0].type == BLOCK_HR, "typing '---' then Enter converts the block to a horizontal rule");
+    CHECK(ed.doc.blocks[0].text.len == 0, "HR block holds no text");
+    CHECK(ed.cursorBlock == 1 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH, "cursor moves into a fresh paragraph after the rule");
+
+    CHECK(document_save_file(&ed.doc, "test_hr.md"), "save succeeds");
+    EditorState ed2;
+    editor_init(&ed2);
+    CHECK(editor_load(&ed2, "test_hr.md"), "load succeeds");
+    CHECK(ed2.doc.count == 1 && ed2.doc.blocks[0].type == BLOCK_HR, "HR round-trips (the trailing empty paragraph doesn't survive, same as any blank line)");
+    editor_free(&ed);
+    editor_free(&ed2);
+}
+
+static void test_task_list(void) {
+    EditorState ed;
+    editor_init(&ed);
+    type_str(&ed, "- ");
+    CHECK(ed.doc.blocks[0].type == BLOCK_BULLET, "'- ' first converts to a bullet");
+    type_str(&ed, "[ ] ");
+    CHECK(ed.doc.blocks[0].type == BLOCK_TASK_UNCHECKED, "'[ ] ' right after turns the bullet into an unchecked task");
+    type_str(&ed, "Buy milk");
+    CHECK(text_eq(&ed.doc.blocks[0], "Buy milk"), "unchecked task holds typed text");
+
+    CHECK(editor_toggle_task(&ed, 0), "toggling the task succeeds");
+    CHECK(ed.doc.blocks[0].type == BLOCK_TASK_CHECKED, "toggle flips unchecked -> checked");
+    CHECK(editor_toggle_task(&ed, 0), "toggling again succeeds");
+    CHECK(ed.doc.blocks[0].type == BLOCK_TASK_UNCHECKED, "toggle flips back checked -> unchecked");
+    CHECK(!editor_toggle_task(&ed, 5), "toggling an out-of-range block index fails");
+
+    editor_toggle_task(&ed, 0); /* leave it checked for the round-trip check below */
+    CHECK(document_save_file(&ed.doc, "test_task.md"), "save succeeds");
+    EditorState ed2;
+    editor_init(&ed2);
+    CHECK(editor_load(&ed2, "test_task.md"), "load succeeds");
+    CHECK(ed2.doc.count == 1 && ed2.doc.blocks[0].type == BLOCK_TASK_CHECKED, "checked task round-trips as '- [x] '");
+    CHECK(text_eq(&ed2.doc.blocks[0], "Buy milk"), "task text round-trips");
+    editor_free(&ed);
+    editor_free(&ed2);
+}
+
+static void test_task_list_load_variants(void) {
+    FILE *f = fopen("test_task_variants.md", "wb");
+    fputs("- [ ] unchecked\n\n- [X] uppercase checked\n", f);
+    fclose(f);
+
+    Document doc;
+    document_init(&doc);
+    CHECK(document_load_file(&doc, "test_task_variants.md"), "load succeeds");
+    CHECK(doc.count == 2, "two task items loaded");
+    CHECK(doc.blocks[0].type == BLOCK_TASK_UNCHECKED, "'- [ ] ' loads as unchecked");
+    CHECK(doc.blocks[1].type == BLOCK_TASK_CHECKED, "'- [X] ' (uppercase X) loads as checked");
+    document_free(&doc);
+}
+
+static void test_code_fence_with_language(void) {
+    EditorState ed;
+    editor_init(&ed);
+    type_str(&ed, "```c");
+    editor_enter(&ed);
+    CHECK(ed.doc.blocks[0].type == BLOCK_CODE, "'```c' then Enter opens a code block");
+    CHECK(ed.doc.blocks[0].lang.len == 1 && ed.doc.blocks[0].lang.data[0] == 'c', "language 'c' is captured");
+
+    type_str(&ed, "int x;");
+    editor_enter(&ed);
+    type_str(&ed, "```");
+    editor_enter(&ed);
+
+    CHECK(document_save_file(&ed.doc, "test_codelang.md"), "save succeeds");
+    EditorState ed2;
+    editor_init(&ed2);
+    CHECK(editor_load(&ed2, "test_codelang.md"), "load succeeds");
+    CHECK(ed2.doc.blocks[0].type == BLOCK_CODE, "code block round-trips");
+    CHECK(ed2.doc.blocks[0].lang.len == 1 && ed2.doc.blocks[0].lang.data[0] == 'c', "language round-trips");
+    CHECK(text_eq(&ed2.doc.blocks[0], "int x;"), "code text round-trips");
+    editor_free(&ed);
+    editor_free(&ed2);
+}
+
+static void test_inline_extended_styles(void) {
+    const char *s = "a ~~b~~ c ==d== e ~f~ g ^h^";
+    InlineRunList runs;
+    inline_parse(s, (int)strlen(s), &runs);
+
+    int haveStrike = 0, haveHighlight = 0, haveSub = 0, haveSuper = 0;
+    for (int i = 0; i < runs.count; i++) {
+        InlineRun *r = &runs.runs[i];
+        if (r->kind == RUN_STRIKE && s[r->contentStart] == 'b') haveStrike = 1;
+        if (r->kind == RUN_HIGHLIGHT && s[r->contentStart] == 'd') haveHighlight = 1;
+        if (r->kind == RUN_SUB && s[r->contentStart] == 'f') haveSub = 1;
+        if (r->kind == RUN_SUPER && s[r->contentStart] == 'h') haveSuper = 1;
+    }
+    CHECK(haveStrike, "inline_parse finds ~~strike~~");
+    CHECK(haveHighlight, "inline_parse finds ==highlight==");
+    CHECK(haveSub, "inline_parse finds ~sub~ (single tilde, distinct from ~~strike~~)");
+    CHECK(haveSuper, "inline_parse finds ^super^");
+    inline_runs_free(&runs);
+}
+
+static void test_inline_autolink(void) {
+    const char *s = "see https://example.com. more text";
+    InlineRunList runs;
+    inline_parse(s, (int)strlen(s), &runs);
+
+    int found = 0;
+    for (int i = 0; i < runs.count; i++) {
+        InlineRun *r = &runs.runs[i];
+        if (r->kind != RUN_LINK) continue;
+        if (r->rawStart != r->contentStart || r->rawEnd != r->contentEnd) continue; /* autolinks have no markers to reveal */
+        const char *expected = "https://example.com";
+        int n = r->contentEnd - r->contentStart;
+        if (n == (int)strlen(expected) && memcmp(s + r->contentStart, expected, (size_t)n) == 0) found = 1;
+    }
+    CHECK(found, "bare https:// URL is autolinked, with the trailing sentence '.' trimmed off");
+    inline_runs_free(&runs);
+}
+
 int main(void) {
     test_heading_autoformat();
     test_bullet_list_continuation();
@@ -456,6 +621,14 @@ int main(void) {
     test_selection_replace_on_backspace_across_blocks();
     test_config_parse_line();
     test_lang_parse_line();
+    test_heading_levels_4_5_6();
+    test_heading_levels_round_trip();
+    test_horizontal_rule();
+    test_task_list();
+    test_task_list_load_variants();
+    test_code_fence_with_language();
+    test_inline_extended_styles();
+    test_inline_autolink();

     printf("\n%s (%d failure%s)\n", g_failures == 0 ? "ALL PASS" : "SOME FAILED", g_failures, g_failures == 1 ? "" : "s");
     return g_failures == 0 ? 0 : 1;
diff --git a/src/theme.h b/src/theme.h
index 16e0636..7120f6a 100644
--- a/src/theme.h
+++ b/src/theme.h
@@ -34,12 +34,19 @@ void theme_init_defaults(void);
 #define COL_MODAL_SCRIM (Clay_Color){0, 0, 0, 120}
 #define COL_MODAL_BORDER (Clay_Color){206, 206, 200, 255}
 #define COL_BUTTON_HOVER (Clay_Color){235, 235, 231, 255}
+#define COL_HR          (Clay_Color){222, 222, 216, 255}
+#define COL_HIGHLIGHT_BG (Clay_Color){255, 240, 130, 130}
+#define COL_H6_TEXT     (Clay_Color){110, 116, 122, 255}

 #define FS_H1 28
 #define FS_H2 23
 #define FS_H3 19
+#define FS_H4 16
+#define FS_H5 14
+#define FS_H6 13
 #define FS_BODY 16
 #define FS_CODE 15
+#define FS_CODE_LANG 11
 #define FS_STATUSBAR 12
 #define FS_MODAL 15

@@ -54,4 +61,7 @@ void theme_init_defaults(void);
 #define STATUSBAR_HEIGHT 24
 #define STATUSBAR_PADDING 10

+#define HR_THICKNESS 1
+#define HR_TOP_PAD 14
+
 #endif
diff --git a/src/undo.h b/src/undo.h
index 8041016..e82f46a 100644
--- a/src/undo.h
+++ b/src/undo.h
@@ -23,7 +23,7 @@ void snapshot_stack_push(SnapshotStack *s, const Document *doc, int cursorBlock,
    eventually document_free it). Returns false if the stack is empty. */
 bool snapshot_stack_pop(SnapshotStack *s, Snapshot *out);

-typedef enum { EDIT_NONE, EDIT_TYPE, EDIT_BACKSPACE, EDIT_DELETE_FWD, EDIT_ENTER, EDIT_PASTE } EditKind;
+typedef enum { EDIT_NONE, EDIT_TYPE, EDIT_BACKSPACE, EDIT_DELETE_FWD, EDIT_ENTER, EDIT_PASTE, EDIT_TOGGLE_TASK } EditKind;

 typedef struct {
     SnapshotStack undo, redo;