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.** `` 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;