commit a474a08dfad3d5ff818aff88e07d23e77011c51a
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Wed Aug 26 07:35:24 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Wed Aug 26 07:35:24 2026 +0200
Add "@" quick-insert menu and left-margin "Turn Into" menu
Typing "@word" in an empty paragraph pops up a live-filtered, keyboard-
and mouse-navigable list of block types (headings, lists, quote, code,
task, divider) to convert the block into. A "..." icon next to the
current block opens the same list to change an existing block's type
while preserving its content (divider excluded there, since it can't
preserve anything). Both popups flip upward when there isn't room to
open downward, and share a headless, unit-tested core (block_type_menu.c)
for the item table, filtering, and trigger-condition logic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
CMakeLists.txt | 2 +
assets/lang/en.lang | 18 +++++
assets/lang/sv.lang | 18 +++++
src/app_mode.h | 22 ++++++
src/block_type_menu.c | 54 +++++++++++++++
src/block_type_menu.h | 34 +++++++++
src/editor.c | 49 +++++++++++++
src/editor.h | 8 +++
src/lang.c | 28 ++++++++
src/lang.h | 14 ++++
src/main.c | 110 +++++++++++++++++++++++++++--
src/render.c | 188 +++++++++++++++++++++++++++++++++++++++++++++++++-
src/render.h | 12 +++-
src/test_main.c | 155 +++++++++++++++++++++++++++++++++++++++++
src/theme.h | 7 ++
src/undo.h | 3 +-
16 files changed, 711 insertions(+), 11 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8e09709..19ce2cd 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -70,6 +70,7 @@ add_executable(hush
src/lang.c
src/paths.c
src/editor.c
+ src/block_type_menu.c
src/render.c
src/markdown_io.c
src/main.c
@@ -112,6 +113,7 @@ add_executable(hush_test
src/lang.c
src/paths.c
src/editor.c
+ src/block_type_menu.c
src/render.c
src/markdown_io.c
src/test_main.c
diff --git a/assets/lang/en.lang b/assets/lang/en.lang
index 4a3ad0e..8c9c35b 100644
--- a/assets/lang/en.lang
+++ b/assets/lang/en.lang
@@ -45,7 +45,25 @@ help.backspace_delete = Backspace / Delete — Delete a character
help.home_end = Home / End — Start/end of line
help.source_view = Ctrl+M — View raw markdown source
help.zoom = Ctrl++ / Ctrl+- — Zoom in / out (Ctrl+0 resets)
+help.quick_insert = "@" in an empty paragraph — insert menu
help.close = Close
# Shown in the status bar while the raw-source preview (Ctrl+M) is open
source_hint = Raw markdown source — read-only · Ctrl+M or Esc to go back
+
+# Block-type names, shown in the "@" quick-insert popup and the left-margin "Turn Into" menu
+block.h1 = Heading 1
+block.h2 = Heading 2
+block.h3 = Heading 3
+block.h4 = Heading 4
+block.h5 = Heading 5
+block.h6 = Heading 6
+block.bullet = Bullet list
+block.numbered = Numbered list
+block.quote = Quote
+block.code = Code block
+block.task = Task list
+block.hr = Horizontal rule
+
+# Shown in the "@" quick-insert popup when nothing matches the typed filter
+quick_insert.empty = No matches
diff --git a/assets/lang/sv.lang b/assets/lang/sv.lang
index 6f699fa..769549f 100644
--- a/assets/lang/sv.lang
+++ b/assets/lang/sv.lang
@@ -42,7 +42,25 @@ help.backspace_delete = Backspace / Delete — Ta bort tecken
help.home_end = Home / End — Radens början/slut
help.source_view = Ctrl+M — Visa rå markdown-källkod
help.zoom = Ctrl++ / Ctrl+- — Zooma in / ut (Ctrl+0 återställer)
+help.quick_insert = "@" i ett tomt stycke — infogningsmeny
help.close = Stäng
# Visas i statusfältet när källkodsvyn (Ctrl+M) är öppen
source_hint = Rå markdown-källkod — skrivskyddad · Ctrl+M eller Esc för att gå tillbaka
+
+# Blocktypnamn, visas i "@"-snabbmenyn och vänstermarginalens "Gör om till"-meny
+block.h1 = Rubrik 1
+block.h2 = Rubrik 2
+block.h3 = Rubrik 3
+block.h4 = Rubrik 4
+block.h5 = Rubrik 5
+block.h6 = Rubrik 6
+block.bullet = Punktlista
+block.numbered = Numrerad lista
+block.quote = Citat
+block.code = Kodblock
+block.task = Checklista
+block.hr = Horisontell linje
+
+# Visas i "@"-snabbmenyn när inget matchar det skrivna filtret
+quick_insert.empty = Inga träffar
diff --git a/src/app_mode.h b/src/app_mode.h
index 72a2289..f0889b2 100644
--- a/src/app_mode.h
+++ b/src/app_mode.h
@@ -30,4 +30,26 @@ typedef struct {
bool showError; /* true if the last attempt to open `path` failed */
} OpenPromptState;
+/* The left-margin "Turn Into" popup, anchored to ed->cursorBlock. Owned by main.c. */
+typedef struct {
+ bool open;
+} TurnIntoMenuState;
+
+/* The "@" quick-insert popup. `selectedIndex` indexes into the *filtered* row list (i.e. after
+ skipping items block_type_menu_item_matches rejects), not BLOCK_TYPE_MENU_ITEMS directly.
+ Owned by main.c. */
+typedef struct {
+ bool open;
+ int selectedIndex;
+} QuickInsertMenuState;
+
+/* Out-params render_frame fills in for whichever block-type menu(s) the frame's click landed
+ on; -1 means "no click this frame" for the BlockType-typed fields. */
+typedef struct {
+ int quickInsertClickedType; /* BlockType cast to int, or -1 */
+ bool turnIntoIconClicked;
+ int turnIntoClickedType; /* BlockType cast to int, or -1 */
+ bool turnIntoClickedOutside;
+} BlockTypeMenuClickResult;
+
#endif
diff --git a/src/block_type_menu.c b/src/block_type_menu.c
new file mode 100644
index 0000000..43ee6a2
--- /dev/null
+++ b/src/block_type_menu.c
@@ -0,0 +1,54 @@
+#include "block_type_menu.h"
+#include <string.h>
+#include <ctype.h>
+
+/* Icon glyphs are picked conservatively: reuse codepoints this codebase already renders
+ correctly elsewhere (bullet "\xE2\x80\xA2", ballot box "\xE2\x98\x90", em dash "\xE2\x80\x94"
+ -- see render.c's existing markers and lang.c's DEFAULTS), or fall back to plain ASCII where
+ no such precedent exists, rather than guessing at an unproven glyph. */
+const BlockTypeMenuItem BLOCK_TYPE_MENU_ITEMS[BLOCK_TYPE_MENU_ITEM_COUNT] = {
+ { BLOCK_H1, STR_BLOCK_H1, "H1", "heading1 h1 title", true },
+ { BLOCK_H2, STR_BLOCK_H2, "H2", "heading2 h2", true },
+ { BLOCK_H3, STR_BLOCK_H3, "H3", "heading3 h3", true },
+ { BLOCK_H4, STR_BLOCK_H4, "H4", "heading4 h4", true },
+ { BLOCK_H5, STR_BLOCK_H5, "H5", "heading5 h5", true },
+ { BLOCK_H6, STR_BLOCK_H6, "H6", "heading6 h6", true },
+ { BLOCK_BULLET, STR_BLOCK_BULLET, "\xE2\x80\xA2", "bullet list unordered", true },
+ { BLOCK_NUMBERED, STR_BLOCK_NUMBERED, "1.", "numbered ordered list", true },
+ { BLOCK_QUOTE, STR_BLOCK_QUOTE, ">", "quote blockquote", true },
+ { BLOCK_CODE, STR_BLOCK_CODE, "```", "code codeblock fence", true },
+ { BLOCK_TASK_UNCHECKED, STR_BLOCK_TASK, "\xE2\x98\x90", "task todo checkbox checklist", true },
+ { BLOCK_HR, STR_BLOCK_HR, "\xE2\x80\x94", "hr rule divider horizontal", false },
+};
+
+static bool ci_contains(const char *haystack, const char *needle, int needleLen) {
+ if (needleLen == 0) return true;
+ size_t haystackLen = strlen(haystack);
+ if ((size_t)needleLen > haystackLen) return false;
+ for (size_t start = 0; start + (size_t)needleLen <= haystackLen; start++) {
+ bool match = true;
+ for (int i = 0; i < needleLen; i++) {
+ if (tolower((unsigned char)haystack[start + (size_t)i]) != tolower((unsigned char)needle[i])) {
+ match = false;
+ break;
+ }
+ }
+ if (match) return true;
+ }
+ return false;
+}
+
+bool block_type_menu_item_matches(const BlockTypeMenuItem *item, const char *filter, int filterLen) {
+ return ci_contains(item->keyword, filter, filterLen);
+}
+
+bool block_type_menu_quick_insert_trigger_active(const EditorState *ed) {
+ const Block *b = &ed->doc.blocks[ed->cursorBlock];
+ if (b->type != BLOCK_PARAGRAPH) return false;
+ if (b->text.len < 1 || b->text.data[0] != '@') return false;
+ if (ed->cursorOffset != b->text.len) return false;
+ for (int i = 0; i < b->text.len; i++) {
+ if (b->text.data[i] == ' ') return false;
+ }
+ return true;
+}
diff --git a/src/block_type_menu.h b/src/block_type_menu.h
new file mode 100644
index 0000000..c120b59
--- /dev/null
+++ b/src/block_type_menu.h
@@ -0,0 +1,34 @@
+#ifndef HUSH_BLOCK_TYPE_MENU_H
+#define HUSH_BLOCK_TYPE_MENU_H
+
+#include "document.h"
+#include "editor.h"
+#include "lang.h"
+#include <stdbool.h>
+
+/* One row shared by both the "@" quick-insert popup and the left-margin "Turn Into" menu. */
+typedef struct {
+ BlockType type;
+ StringId label;
+ const char *icon; /* short UTF-8 glyph or plain-text hint -- see block_type_menu.c for
+ why each one was picked (only already-proven-safe codepoints, or
+ plain ASCII, are used). */
+ const char *keyword; /* lowercase ASCII search alias(es), space-joined if more than one */
+ bool offerInTurnInto; /* false only for BLOCK_HR -- Turn Into always preserves the target
+ block's content, and HR cannot hold any, so offering it there
+ would silently discard whatever the block held. */
+} BlockTypeMenuItem;
+
+#define BLOCK_TYPE_MENU_ITEM_COUNT 12
+extern const BlockTypeMenuItem BLOCK_TYPE_MENU_ITEMS[BLOCK_TYPE_MENU_ITEM_COUNT];
+
+/* Case-insensitive substring match of `filter` against the item's keyword(s). An empty filter
+ matches every item. */
+bool block_type_menu_item_matches(const BlockTypeMenuItem *item, const char *filter, int filterLen);
+
+/* True iff ed->cursorBlock is a BLOCK_PARAGRAPH whose text is "@" followed by a run of one or
+ more non-space characters, with the cursor sitting at the very end of it -- i.e. the "@word"
+ is actively being typed, not left behind by a click or arrow-key move elsewhere in the block. */
+bool block_type_menu_quick_insert_trigger_active(const EditorState *ed);
+
+#endif
diff --git a/src/editor.c b/src/editor.c
index 4c1e9e5..8893765 100644
--- a/src/editor.c
+++ b/src/editor.c
@@ -477,3 +477,52 @@ bool editor_toggle_task(EditorState *ed, int blockIndex) {
undo_manager_after_edit(&ed->undo, EDIT_TOGGLE_TASK, ed->cursorBlock, ed->cursorOffset);
return true;
}
+
+/* Shared by editor_quick_insert_apply/editor_turn_into_apply below. Always targets
+ ed->cursorBlock -- both callers are anchored there by construction (the quick-insert popup
+ and the Turn Into icon both only ever act on the block the cursor is currently in). */
+static void set_block_type_raw(EditorState *ed, BlockType newType) {
+ Block *b = &ed->doc.blocks[ed->cursorBlock];
+ if (b->type == BLOCK_CODE && newType != BLOCK_CODE) {
+ /* No other block type's renderer expects an embedded '\n' -- scrub it the same way
+ backspace_raw's code-block demotion already does. */
+ for (int i = 0; i < b->text.len; i++) if (b->text.data[i] == '\n') b->text.data[i] = ' ';
+ sb_clear(&b->lang);
+ }
+ if (newType == BLOCK_HR) {
+ /* HR can't hold a cursor, so give it an empty paragraph to land in right after --
+ mirrors the existing "---" + space/Enter autoformat paths exactly. */
+ sb_clear(&b->text);
+ b->type = BLOCK_HR;
+ document_insert_block(&ed->doc, ed->cursorBlock + 1, BLOCK_PARAGRAPH, "", 0);
+ ed->cursorBlock++;
+ ed->cursorOffset = 0;
+ } else {
+ b->type = newType;
+ }
+ ed->dirty = true;
+ reset_preferred_x(ed);
+}
+
+/* Called from the "@" quick-insert popup: the target block is always just the bare "@word" the
+ user typed (see block_type_menu_quick_insert_trigger_active), so it's cleared before the type
+ is set -- there's nothing worth preserving. */
+void editor_quick_insert_apply(EditorState *ed, BlockType newType) {
+ undo_manager_before_edit(&ed->undo, EDIT_QUICK_INSERT, &ed->doc, ed->cursorBlock, ed->cursorOffset);
+ Block *b = &ed->doc.blocks[ed->cursorBlock];
+ sb_clear(&b->text);
+ ed->cursorOffset = 0;
+ set_block_type_raw(ed, newType);
+ snap_anchor_to_cursor(ed, false);
+ undo_manager_after_edit(&ed->undo, EDIT_QUICK_INSERT, ed->cursorBlock, ed->cursorOffset);
+}
+
+/* Called from the left-margin "Turn Into" menu: unlike quick-insert, the target block may hold
+ real content, which this always preserves (newType == BLOCK_HR is never offered there, since
+ HR can't hold any). Ignores any active selection -- Turn Into acts on the whole block. */
+void editor_turn_into_apply(EditorState *ed, BlockType newType) {
+ undo_manager_before_edit(&ed->undo, EDIT_TURN_INTO, &ed->doc, ed->cursorBlock, ed->cursorOffset);
+ set_block_type_raw(ed, newType);
+ snap_anchor_to_cursor(ed, false);
+ undo_manager_after_edit(&ed->undo, EDIT_TURN_INTO, ed->cursorBlock, ed->cursorOffset);
+}
diff --git a/src/editor.h b/src/editor.h
index 9f14335..733d535 100644
--- a/src/editor.h
+++ b/src/editor.h
@@ -43,6 +43,14 @@ bool editor_redo(EditorState *ed);
`blockIndex` is out of range or isn't a task block. */
bool editor_toggle_task(EditorState *ed, int blockIndex);
+/* Converts ed->cursorBlock to `newType`, undo-wrapped. quick_insert clears the block's text
+ first (it's only ever called on a bare "@word" typed for exactly this purpose); turn_into
+ preserves whatever the block already held. Both scrub embedded '\n' bytes and clear `lang`
+ when leaving BLOCK_CODE, and both handle BLOCK_HR's "insert a following empty paragraph and
+ move the cursor there" special case (quick_insert only -- turn_into never offers BLOCK_HR). */
+void editor_quick_insert_apply(EditorState *ed, BlockType newType);
+void editor_turn_into_apply(EditorState *ed, BlockType newType);
+
/* `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/lang.c b/src/lang.c
index 8da84de..d663033 100644
--- a/src/lang.c
+++ b/src/lang.c
@@ -35,8 +35,22 @@ static const char *KEY_NAMES[STR_COUNT] = {
[STR_HELP_HOME_END] = "help.home_end",
[STR_HELP_SOURCE_VIEW] = "help.source_view",
[STR_HELP_ZOOM] = "help.zoom",
+ [STR_HELP_QUICK_INSERT] = "help.quick_insert",
[STR_HELP_CLOSE] = "help.close",
[STR_SOURCE_HINT] = "source_hint",
+ [STR_BLOCK_H1] = "block.h1",
+ [STR_BLOCK_H2] = "block.h2",
+ [STR_BLOCK_H3] = "block.h3",
+ [STR_BLOCK_H4] = "block.h4",
+ [STR_BLOCK_H5] = "block.h5",
+ [STR_BLOCK_H6] = "block.h6",
+ [STR_BLOCK_BULLET] = "block.bullet",
+ [STR_BLOCK_NUMBERED] = "block.numbered",
+ [STR_BLOCK_QUOTE] = "block.quote",
+ [STR_BLOCK_CODE] = "block.code",
+ [STR_BLOCK_TASK] = "block.task",
+ [STR_BLOCK_HR] = "block.hr",
+ [STR_QUICK_INSERT_EMPTY] = "quick_insert.empty",
};
/* Built-in fallback, used for any key missing from the loaded language file (including when
@@ -71,8 +85,22 @@ static const char *DEFAULTS[STR_COUNT] = {
[STR_HELP_HOME_END] = "Home / End \xE2\x80\x94 Start/end of line",
[STR_HELP_SOURCE_VIEW] = "Ctrl+M \xE2\x80\x94 View raw markdown source",
[STR_HELP_ZOOM] = "Ctrl++ / Ctrl+- \xE2\x80\x94 Zoom in / out (Ctrl+0 resets)",
+ [STR_HELP_QUICK_INSERT] = "\"@\" in an empty paragraph \xE2\x80\x94 insert menu",
[STR_HELP_CLOSE] = "Close",
[STR_SOURCE_HINT] = "Raw markdown source \xE2\x80\x94 read-only \xC2\xB7 Ctrl+M or Esc to go back",
+ [STR_BLOCK_H1] = "Heading 1",
+ [STR_BLOCK_H2] = "Heading 2",
+ [STR_BLOCK_H3] = "Heading 3",
+ [STR_BLOCK_H4] = "Heading 4",
+ [STR_BLOCK_H5] = "Heading 5",
+ [STR_BLOCK_H6] = "Heading 6",
+ [STR_BLOCK_BULLET] = "Bullet list",
+ [STR_BLOCK_NUMBERED] = "Numbered list",
+ [STR_BLOCK_QUOTE] = "Quote",
+ [STR_BLOCK_CODE] = "Code block",
+ [STR_BLOCK_TASK] = "Task list",
+ [STR_BLOCK_HR] = "Horizontal rule",
+ [STR_QUICK_INSERT_EMPTY] = "No matches",
};
static char *g_strings[STR_COUNT];
diff --git a/src/lang.h b/src/lang.h
index b751ff6..c52c71f 100644
--- a/src/lang.h
+++ b/src/lang.h
@@ -36,8 +36,22 @@ typedef enum {
STR_HELP_HOME_END,
STR_HELP_SOURCE_VIEW,
STR_HELP_ZOOM,
+ STR_HELP_QUICK_INSERT,
STR_HELP_CLOSE,
STR_SOURCE_HINT,
+ STR_BLOCK_H1,
+ STR_BLOCK_H2,
+ STR_BLOCK_H3,
+ STR_BLOCK_H4,
+ STR_BLOCK_H5,
+ STR_BLOCK_H6,
+ STR_BLOCK_BULLET,
+ STR_BLOCK_NUMBERED,
+ STR_BLOCK_QUOTE,
+ STR_BLOCK_CODE,
+ STR_BLOCK_TASK,
+ STR_BLOCK_HR,
+ STR_QUICK_INSERT_EMPTY,
STR_COUNT,
} StringId;
diff --git a/src/main.c b/src/main.c
index 61e4f51..7f00601 100644
--- a/src/main.c
+++ b/src/main.c
@@ -7,6 +7,7 @@
#include "fonts.h"
#include "theme.h"
#include "app_mode.h"
+#include "block_type_menu.h"
#include "config.h"
#include "keymap.h"
#include "lang.h"
@@ -30,6 +31,17 @@ static void update_window_title(SDL_Window *window, EditorState *ed) {
SDL_SetWindowTitle(window, title);
}
+/* Whether `point` (current mouse position) falls inside the element `id`'s last-known bounding
+ box. Queried before this frame's render_frame/Clay_BeginLayout call, so it reflects the
+ previous frame's layout -- fine in practice since these floating popups don't move frame to
+ frame absent user action, the same assumption Clay_PointerOver itself relies on internally. */
+static bool point_in_element(Clay_ElementId id, Clay_Vector2 point) {
+ Clay_ElementData d = Clay_GetElementData(id);
+ if (!d.found) return false;
+ return point.x >= d.boundingBox.x && point.x <= d.boundingBox.x + d.boundingBox.width
+ && point.y >= d.boundingBox.y && point.y <= d.boundingBox.y + d.boundingBox.height;
+}
+
static char *dup_str_or_null(const char *s) {
if (!s) return NULL;
size_t n = strlen(s);
@@ -182,6 +194,22 @@ int main(int argc, char **argv) {
document drag-select once mode flips back to MODE_EDITING. */
bool mouseDownInEditor = false;
+ /* The left-margin "Turn Into" popup: whether it's open, and which block it was opened for
+ (so moving the cursor to a different block auto-closes it rather than silently retargeting
+ a menu the user can no longer see is misaligned). */
+ bool turnIntoOpen = false;
+ int turnIntoAnchorBlock = -1;
+
+ /* The "@" quick-insert popup. `dismissed` is a per-trigger latch set by Escape or a
+ conversion, cleared again the moment the trigger condition re-edges true (a fresh "@" or a
+ move to a different block) -- see the reset logic below for why this can't just be "open
+ whenever the trigger condition holds". */
+ bool quickInsertOpen = false;
+ bool quickInsertDismissed = false;
+ int qiSelectedIndex = 0;
+ int qiLastCursorBlock = -1;
+ bool qiWasOpenLastFrame = false;
+
while (!windowClosing) {
double now = now_seconds();
float deltaTime = (float)(now - lastFrameNow);
@@ -256,28 +284,84 @@ int main(int argc, char **argv) {
theme_zoom_out();
} else if (keymap_pressed(ACTION_ZOOM_RESET, ctrl, shift, alt) || (ctrl && keymap_key_pressed_raw(SDLK_KP_0))) {
theme_zoom_reset();
+ } else if (turnIntoOpen && keymap_key_pressed_raw(SDLK_ESCAPE)) {
+ turnIntoOpen = false;
} else {
if (!ctrl && fi.textInputLen > 0) {
editor_insert_utf8(&ed, fi.textInput, fi.textInputLen);
}
- if (keymap_key_pressed_raw(SDLK_RETURN) || keymap_key_pressed_raw(SDLK_KP_ENTER)) editor_enter(&ed);
+
+ /* Re-derive the "@" quick-insert popup's open state from the document itself
+ (see block_type_menu_quick_insert_trigger_active) rather than tracking it as
+ independent input-capture state -- the popup's filter text IS the block's own
+ "@word" content. `quickInsertDismissed` is a latch (Escape or a conversion) that
+ must NOT re-trigger just because the trigger condition still holds -- it only
+ resets on a genuinely fresh trigger edge (a new "@" typed, or the cursor landing
+ in a different block that also happens to read "@word"). */
+ bool qiShouldOpen = block_type_menu_quick_insert_trigger_active(&ed);
+ if (qiShouldOpen && (!qiWasOpenLastFrame || ed.cursorBlock != qiLastCursorBlock)) {
+ quickInsertDismissed = false;
+ qiSelectedIndex = 0;
+ }
+ qiLastCursorBlock = ed.cursorBlock;
+ qiWasOpenLastFrame = qiShouldOpen;
+ quickInsertOpen = qiShouldOpen && !quickInsertDismissed && !turnIntoOpen;
+
+ if (quickInsertOpen) {
+ Block *qb = &ed.doc.blocks[ed.cursorBlock];
+ const char *filter = qb->text.data + 1;
+ int filterLen = qb->text.len - 1;
+ int filteredCount = 0;
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ if (block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], filter, filterLen)) filteredCount++;
+ }
+ if (filteredCount > 0) {
+ if (keymap_key_pressed_raw(SDLK_DOWN)) qiSelectedIndex = (qiSelectedIndex + 1) % filteredCount;
+ if (keymap_key_pressed_raw(SDLK_UP)) qiSelectedIndex = (qiSelectedIndex - 1 + filteredCount) % filteredCount;
+ if (qiSelectedIndex >= filteredCount) qiSelectedIndex = 0;
+ if (keymap_key_pressed_raw(SDLK_RETURN) || keymap_key_pressed_raw(SDLK_KP_ENTER)) {
+ BlockType chosenType = BLOCK_PARAGRAPH;
+ int idx = 0;
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ if (!block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], filter, filterLen)) continue;
+ if (idx == qiSelectedIndex) { chosenType = BLOCK_TYPE_MENU_ITEMS[i].type; break; }
+ idx++;
+ }
+ editor_quick_insert_apply(&ed, chosenType);
+ quickInsertDismissed = true;
+ }
+ }
+ if (keymap_key_pressed_raw(SDLK_ESCAPE)) quickInsertDismissed = true;
+ } else {
+ if (keymap_key_pressed_raw(SDLK_RETURN) || keymap_key_pressed_raw(SDLK_KP_ENTER)) editor_enter(&ed);
+ if (keymap_repeat_scancode(SDL_SCANCODE_UP, now, &repUp)) editor_move_vertical(&ed, &cache, fonts, -1, shift);
+ if (keymap_repeat_scancode(SDL_SCANCODE_DOWN, now, &repDown)) editor_move_vertical(&ed, &cache, fonts, 1, shift);
+ }
if (keymap_repeat_scancode(SDL_SCANCODE_BACKSPACE, now, &repBackspace)) editor_backspace(&ed);
if (keymap_repeat_scancode(SDL_SCANCODE_DELETE, now, &repDelete)) editor_delete_forward(&ed);
if (keymap_repeat_scancode(SDL_SCANCODE_LEFT, now, &repLeft)) editor_move_left(&ed, shift);
if (keymap_repeat_scancode(SDL_SCANCODE_RIGHT, now, &repRight)) editor_move_right(&ed, shift);
- if (keymap_repeat_scancode(SDL_SCANCODE_UP, now, &repUp)) editor_move_vertical(&ed, &cache, fonts, -1, shift);
- if (keymap_repeat_scancode(SDL_SCANCODE_DOWN, now, &repDown)) editor_move_vertical(&ed, &cache, fonts, 1, shift);
if (keymap_key_pressed_raw(SDLK_HOME)) editor_move_home(&ed, shift);
if (keymap_key_pressed_raw(SDLK_END)) editor_move_end(&ed, shift);
}
- if (fi.mouseLeftPressed) {
+ bool overTurnIntoUi = point_in_element(CLAY_ID("TurnIntoIcon"), mousePos)
+ || (turnIntoOpen && point_in_element(CLAY_ID("TurnIntoMenuBox"), mousePos));
+ bool overQuickInsertUi = quickInsertOpen && point_in_element(CLAY_ID("QuickInsertMenu"), mousePos);
+ if (fi.mouseLeftPressed && !overTurnIntoUi && !overQuickInsertUi) {
editor_click(&ed, &cache, fonts, mousePos);
mouseDownInEditor = true;
- } else if (mouseDownInEditor && mouseLeftDown) {
+ } else if (mouseDownInEditor && mouseLeftDown && !overTurnIntoUi && !overQuickInsertUi) {
editor_drag_to(&ed, &cache, fonts, mousePos);
}
+ if (turnIntoOpen && ed.cursorBlock != turnIntoAnchorBlock) turnIntoOpen = false;
+ /* Safety net for state changes the catch-all above didn't see this frame (a mouse
+ click moved the cursor without any key press) -- forces the popup closed the
+ instant the pure trigger condition stops holding, rather than leaving it rendered
+ (mis-anchored to whatever block the cursor now sits in) until the next keystroke. */
+ if (quickInsertOpen && !block_type_menu_quick_insert_trigger_active(&ed)) quickInsertOpen = false;
+
/* SDL_QUIT is naturally exactly-once per actual close-button click (SDL_PollEvent
returns each event once) -- no self-reset workaround needed here, unlike raylib's
WindowShouldClose(). */
@@ -338,8 +422,12 @@ int main(int argc, char **argv) {
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
int toggledTaskBlock;
+ TurnIntoMenuState turnIntoMenuState = { .open = turnIntoOpen };
+ QuickInsertMenuState quickInsertMenuState = { .open = quickInsertOpen, .selectedIndex = qiSelectedIndex };
+ BlockTypeMenuClickResult menuClick;
ModalAction action = render_frame(&ed, &cache, fonts, renderer, fi.mouseLeftPressed, (float)winW, (float)winH,
- deltaTime, caretBlinkT, mode, &toggledTaskBlock, &openPrompt, confirmForOpen);
+ deltaTime, caretBlinkT, mode, &toggledTaskBlock, &openPrompt, confirmForOpen,
+ &turnIntoMenuState, &quickInsertMenuState, &menuClick);
SDL_RenderPresent(renderer);
switch (action) {
@@ -358,6 +446,16 @@ int main(int argc, char **argv) {
default: break;
}
if (mode == MODE_EDITING && toggledTaskBlock >= 0) editor_toggle_task(&ed, toggledTaskBlock);
+
+ if (menuClick.turnIntoIconClicked) { turnIntoOpen = !turnIntoOpen; turnIntoAnchorBlock = ed.cursorBlock; }
+ if (menuClick.turnIntoClickedType >= 0) { editor_turn_into_apply(&ed, (BlockType)menuClick.turnIntoClickedType); turnIntoOpen = false; }
+ if (menuClick.turnIntoClickedOutside) turnIntoOpen = false;
+
+ if (menuClick.quickInsertClickedType >= 0) {
+ editor_quick_insert_apply(&ed, (BlockType)menuClick.quickInsertClickedType);
+ quickInsertDismissed = true;
+ quickInsertOpen = false;
+ }
}
layout_cache_free(&cache);
diff --git a/src/render.c b/src/render.c
index 395dea6..f8af359 100644
--- a/src/render.c
+++ b/src/render.c
@@ -7,6 +7,7 @@
#include "hittest.h"
#include "markdown_io.h"
#include "lang.h"
+#include "block_type_menu.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
@@ -28,6 +29,8 @@ typedef struct {
int cursorOffset;
int *toggledTaskBlock; /* set to a block index this frame if its task checkbox was clicked */
bool mouseLeftPressed; /* this frame's left-click edge; Clay has no input polling under SDL2 */
+ float winH; /* current window height in pixels -- used to flip block-type popups upward when
+ there isn't enough room to open them downward */
} RenderCtx;
/* Segments that need a strikethrough line drawn through them once their final screen position
@@ -531,6 +534,7 @@ static ModalAction emit_modal(AppMode mode, float winW, float winH, const OpenPr
modal_text_line(lang_get(STR_HELP_HOME_END));
modal_text_line(lang_get(STR_HELP_SOURCE_VIEW));
modal_text_line(lang_get(STR_HELP_ZOOM));
+ modal_text_line(lang_get(STR_HELP_QUICK_INSERT));
modal_button(CLAY_ID("BtnCloseHelp"), lang_get(STR_HELP_CLOSE), &action, MODAL_ACTION_CLOSE_HELP, mouseLeftPressed);
}
}
@@ -538,6 +542,175 @@ static ModalAction emit_modal(AppMode mode, float winW, float winH, const OpenPr
return action;
}
+/* One row shared by the "@" quick-insert popup and the left-margin "Turn Into" menu: an icon
+ glyph + label, highlighted on hover or (once Phase 2 wires keyboard nav in) on
+ `keyboardSelected`. Sets *outClickedType to the row's BlockType on click. */
+static void block_type_menu_row(Clay_ElementId id, const BlockTypeMenuItem *item, bool keyboardSelected,
+ bool mouseLeftPressed, int *outClickedType) {
+ bool hovered = Clay_PointerOver(id);
+ CLAY(id, {
+ .layout = {
+ .layoutDirection = CLAY_LEFT_TO_RIGHT,
+ .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(BLOCK_TYPE_MENU_ROW_HEIGHT) },
+ .padding = { 10, 10, 0, 0 },
+ .childGap = 10,
+ .childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
+ },
+ .backgroundColor = (hovered || keyboardSelected) ? COL_BUTTON_HOVER : g_theme.bg,
+ .cornerRadius = CLAY_CORNER_RADIUS(4),
+ }) {
+ Clay_String icon = { .isStaticallyAllocated = true, .length = (int)strlen(item->icon), .chars = item->icon };
+ CLAY_AUTO_ID({ .layout = { .sizing = { CLAY_SIZING_FIXED(28), CLAY_SIZING_FIT(0) } } }) {
+ CLAY_TEXT(icon, CLAY_TEXT_CONFIG({ .fontId = FONT_MONO_REGULAR, .fontSize = FS_MODAL, .textColor = COL_MARKER }));
+ }
+ const char *label = lang_get(item->label);
+ Clay_String s = { .isStaticallyAllocated = true, .length = (int)strlen(label), .chars = label };
+ CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = FS_MODAL, .textColor = g_theme.text }));
+ }
+ if (hovered && mouseLeftPressed && outClickedType) *outClickedType = (int)item->type;
+}
+
+typedef struct {
+ Clay_FloatingAttachPoints attachPoints;
+ Clay_Vector2 offset;
+} BlockTypeMenuPlacement;
+
+/* Decides whether a block-type popup anchored below `anchorId` should instead open upward --
+ there isn't `rowCount` rows' worth of room before the window bottom. Reads `anchorId`'s
+ bounding box as of the last completed layout (the same previous-frame-data idiom
+ Clay_PointerOver already relies on elsewhere in this file -- these elements don't move except
+ in response to user action, so a one-frame-stale position is a non-issue in practice). */
+static BlockTypeMenuPlacement block_type_menu_placement(Clay_ElementId anchorId, int rowCount, float winH) {
+ float menuHeight = (float)(rowCount > 0 ? rowCount : 1) * BLOCK_TYPE_MENU_ROW_HEIGHT + 8.0f;
+ Clay_ElementData d = Clay_GetElementData(anchorId);
+ bool openUpward = d.found && (d.boundingBox.y + d.boundingBox.height + BLOCK_TYPE_MENU_GAP + menuHeight > winH);
+ if (openUpward) {
+ return (BlockTypeMenuPlacement){
+ .attachPoints = { .element = CLAY_ATTACH_POINT_LEFT_BOTTOM, .parent = CLAY_ATTACH_POINT_LEFT_TOP },
+ .offset = { 0, -BLOCK_TYPE_MENU_GAP },
+ };
+ }
+ return (BlockTypeMenuPlacement){
+ .attachPoints = { .element = CLAY_ATTACH_POINT_LEFT_TOP, .parent = CLAY_ATTACH_POINT_LEFT_BOTTOM },
+ .offset = { 0, BLOCK_TYPE_MENU_GAP },
+ };
+}
+
+/* The left-margin "Turn Into" icon (always anchored to ed->cursorBlock) and, when `ti->open`,
+ its dropdown -- lets the user change an *existing* block's type while preserving its content.
+ Never offers BLOCK_HR (see BlockTypeMenuItem.offerInTurnInto). No-op outside MODE_EDITING
+ (guarded by the caller, since ed->cursorBlock/CLAY_IDI("Block", ...) only exist there). */
+static void emit_turn_into(RenderCtx *ctx, EditorState *ed, const TurnIntoMenuState *ti, BlockTypeMenuClickResult *out) {
+ Clay_ElementId iconId = CLAY_ID("TurnIntoIcon");
+ Clay_ElementId blockId = CLAY_IDI("Block", ed->cursorBlock);
+ bool iconHovered = Clay_PointerOver(iconId);
+
+ CLAY(iconId, {
+ .layout = {
+ .sizing = { CLAY_SIZING_FIXED(TURN_INTO_ICON_SIZE), CLAY_SIZING_FIXED(TURN_INTO_ICON_SIZE) },
+ .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
+ },
+ .backgroundColor = iconHovered ? COL_BUTTON_HOVER : g_theme.bg,
+ .cornerRadius = CLAY_CORNER_RADIUS(4),
+ .floating = {
+ .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID,
+ .parentId = blockId.id,
+ .attachPoints = { .element = CLAY_ATTACH_POINT_RIGHT_TOP, .parent = CLAY_ATTACH_POINT_LEFT_TOP },
+ .offset = { -TURN_INTO_ICON_GAP, 2 },
+ .clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
+ },
+ }) {
+ CLAY_TEXT(CLAY_STRING("..."), CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_BOLD, .fontSize = FS_MODAL, .textColor = COL_MARKER }));
+ }
+ if (iconHovered && ctx->mouseLeftPressed) out->turnIntoIconClicked = true;
+
+ if (!ti->open) return;
+
+ Clay_ElementId menuId = CLAY_ID("TurnIntoMenuBox");
+ bool overMenu = Clay_PointerOver(menuId);
+ if (!iconHovered && !overMenu && ctx->mouseLeftPressed) out->turnIntoClickedOutside = true;
+
+ int turnIntoRowCount = 0;
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ if (BLOCK_TYPE_MENU_ITEMS[i].offerInTurnInto) turnIntoRowCount++;
+ }
+ BlockTypeMenuPlacement placement = block_type_menu_placement(iconId, turnIntoRowCount, ctx->winH);
+
+ CLAY(menuId, {
+ .layout = {
+ .layoutDirection = CLAY_TOP_TO_BOTTOM,
+ .sizing = { CLAY_SIZING_FIXED(TURN_INTO_MENU_WIDTH), CLAY_SIZING_FIT(0) },
+ .padding = CLAY_PADDING_ALL(4),
+ },
+ .backgroundColor = g_theme.bg,
+ .cornerRadius = CLAY_CORNER_RADIUS(6),
+ .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
+ .floating = {
+ .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID,
+ .parentId = iconId.id,
+ .attachPoints = placement.attachPoints,
+ .offset = placement.offset,
+ .clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
+ .zIndex = 5,
+ },
+ }) {
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ const BlockTypeMenuItem *item = &BLOCK_TYPE_MENU_ITEMS[i];
+ if (!item->offerInTurnInto) continue;
+ block_type_menu_row(CLAY_IDI("TurnIntoRow", i), item, false, ctx->mouseLeftPressed, &out->turnIntoClickedType);
+ }
+ }
+}
+
+/* The "@" quick-insert popup: floats below ed->cursorBlock (guaranteed a BLOCK_PARAGRAPH by
+ block_type_menu_quick_insert_trigger_active), filtered live against the block's own "@word"
+ text (everything after the "@"). Unlike Turn Into, every item including BLOCK_HR is offered --
+ the target block is always empty, so there's nothing to lose by clearing it into a divider. */
+static void emit_quick_insert_menu(RenderCtx *ctx, EditorState *ed, const QuickInsertMenuState *qi, BlockTypeMenuClickResult *out) {
+ Block *b = &ed->doc.blocks[ed->cursorBlock];
+ const char *filter = b->text.data + 1;
+ int filterLen = b->text.len - 1;
+
+ int matchCount = 0;
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ if (block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], filter, filterLen)) matchCount++;
+ }
+
+ Clay_ElementId blockId = CLAY_IDI("Block", ed->cursorBlock);
+ BlockTypeMenuPlacement placement = block_type_menu_placement(blockId, matchCount, ctx->winH);
+
+ CLAY(CLAY_ID("QuickInsertMenu"), {
+ .layout = {
+ .layoutDirection = CLAY_TOP_TO_BOTTOM,
+ .sizing = { CLAY_SIZING_FIXED(QUICK_INSERT_MENU_WIDTH), CLAY_SIZING_FIT(0) },
+ .padding = CLAY_PADDING_ALL(4),
+ },
+ .backgroundColor = g_theme.bg,
+ .cornerRadius = CLAY_CORNER_RADIUS(6),
+ .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
+ .floating = {
+ .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID,
+ .parentId = blockId.id,
+ .attachPoints = placement.attachPoints,
+ .offset = placement.offset,
+ .clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
+ .zIndex = 5,
+ },
+ }) {
+ int filteredIdx = 0;
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ const BlockTypeMenuItem *item = &BLOCK_TYPE_MENU_ITEMS[i];
+ if (!block_type_menu_item_matches(item, filter, filterLen)) continue;
+ block_type_menu_row(CLAY_IDI("QuickInsertRow", filteredIdx), item, filteredIdx == qi->selectedIndex,
+ ctx->mouseLeftPressed, &out->quickInsertClickedType);
+ filteredIdx++;
+ }
+ if (filteredIdx == 0) {
+ modal_text_line(lang_get(STR_QUICK_INSERT_EMPTY));
+ }
+ }
+}
+
Clay_Dimensions hush_measure_text(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData) {
SDL2_Font *fonts = (SDL2_Font *)userData;
TTF_Font *font = fonts[config->fontId].font;
@@ -557,17 +730,20 @@ Clay_Dimensions hush_measure_text(Clay_StringSlice text, Clay_TextElementConfig
ModalAction render_frame(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts, SDL_Renderer *renderer,
bool mouseLeftPressed, float winW, float winH, float deltaTime, float caretBlinkT, AppMode mode,
- int *outToggledTaskBlock, const OpenPromptState *openPrompt, bool confirmForOpen) {
+ int *outToggledTaskBlock, const OpenPromptState *openPrompt, bool confirmForOpen,
+ const TurnIntoMenuState *turnIntoMenu, const QuickInsertMenuState *quickInsertMenu,
+ BlockTypeMenuClickResult *outMenuClick) {
g_scratchPos = 0;
g_strikeMarkCount = 0;
layout_cache_reset(cache);
+ *outMenuClick = (BlockTypeMenuClickResult){ .quickInsertClickedType = -1, .turnIntoClickedType = -1 };
float contentWidth = winW - 2.0f * CONTENT_SIDE_PADDING;
if (contentWidth > CONTENT_MAX_WIDTH) contentWidth = CONTENT_MAX_WIDTH;
if (contentWidth < 100.0f) contentWidth = 100.0f;
int toggledTaskBlock = -1;
- RenderCtx ctx = { fonts, cache, contentWidth, ed->cursorBlock, ed->cursorOffset, &toggledTaskBlock, mouseLeftPressed };
+ RenderCtx ctx = { fonts, cache, contentWidth, ed->cursorBlock, ed->cursorOffset, &toggledTaskBlock, mouseLeftPressed, winH };
Clay_BeginLayout();
@@ -627,6 +803,14 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts,
}
}
+ if (mode == MODE_EDITING) {
+ if (quickInsertMenu->open) {
+ emit_quick_insert_menu(&ctx, ed, quickInsertMenu, outMenuClick);
+ } else {
+ emit_turn_into(&ctx, ed, turnIntoMenu, outMenuClick);
+ }
+ }
+
ModalAction modalAction = emit_modal(mode, winW, winH, openPrompt, confirmForOpen, mouseLeftPressed);
Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime);
diff --git a/src/render.h b/src/render.h
index fc3a082..164a208 100644
--- a/src/render.h
+++ b/src/render.h
@@ -32,9 +32,17 @@ Clay_Dimensions hush_measure_text(Clay_StringSlice text, Clay_TextElementConfig
or -1 if none (ignored if NULL) -- caller should follow up with editor_toggle_task.
`openPrompt` is read (never NULL) for MODE_OPEN_PROMPT's typed-path text field; ignored
otherwise. `confirmForOpen` disambiguates MODE_QUIT_CONFIRM's wording: true if it's guarding
- Ctrl+O (unsaved changes before opening another file) rather than Ctrl+X (quitting). */
+ Ctrl+O (unsaved changes before opening another file) rather than Ctrl+X (quitting).
+ `turnIntoMenu` is read (never NULL, ignored outside MODE_EDITING) for the left-margin "Turn
+ Into" popup anchored to ed->cursorBlock. `quickInsertMenu` is read (never NULL, ignored outside
+ MODE_EDITING) for the "@" quick-insert popup, also anchored to ed->cursorBlock -- caller is
+ responsible for the two never being open at once. `outMenuClick` (never NULL) is reset every
+ call and filled in with whichever block-type menu row/icon/outside-click the frame's click
+ resolved to, if any -- caller applies the result after render_frame returns. */
ModalAction render_frame(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts, SDL_Renderer *renderer,
bool mouseLeftPressed, float winW, float winH, float deltaTime, float caretBlinkT, AppMode mode,
- int *outToggledTaskBlock, const OpenPromptState *openPrompt, bool confirmForOpen);
+ int *outToggledTaskBlock, const OpenPromptState *openPrompt, bool confirmForOpen,
+ const TurnIntoMenuState *turnIntoMenu, const QuickInsertMenuState *quickInsertMenu,
+ BlockTypeMenuClickResult *outMenuClick);
#endif
diff --git a/src/test_main.c b/src/test_main.c
index d84331b..3b80a7b 100644
--- a/src/test_main.c
+++ b/src/test_main.c
@@ -9,6 +9,7 @@
#include "lang.h"
#include "theme.h"
#include "keymap.h"
+#include "block_type_menu.h"
#include <SDL.h>
#include <stdio.h>
#include <string.h>
@@ -711,6 +712,159 @@ static void test_inline_autolink(void) {
inline_runs_free(&runs);
}
+static void test_block_type_menu(void) {
+ /* --- trigger condition --- */
+ EditorState ed;
+ editor_init(&ed);
+ type_str(&ed, "@x");
+ CHECK(block_type_menu_quick_insert_trigger_active(&ed), "\"@x\" with the cursor at the end triggers the quick-insert menu");
+
+ sb_clear(&ed.doc.blocks[0].text);
+ ed.cursorOffset = 0;
+ ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
+ CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "a bare empty paragraph (no '@' yet) does not trigger");
+
+ type_str(&ed, "x@y");
+ CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "'@' must be the first character, not just present somewhere");
+
+ sb_clear(&ed.doc.blocks[0].text);
+ ed.cursorOffset = 0;
+ ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
+ type_str(&ed, "@foo bar");
+ CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "a space anywhere in the block cancels the trigger");
+
+ sb_clear(&ed.doc.blocks[0].text);
+ ed.cursorOffset = 0;
+ ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
+ type_str(&ed, "@abc");
+ ed.cursorOffset = 1;
+ ed.selAnchorOffset = ed.cursorOffset;
+ CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "cursor not at the end of the '@word' does not trigger");
+
+ ed.doc.blocks[0].type = BLOCK_H1;
+ ed.cursorOffset = ed.doc.blocks[0].text.len;
+ ed.selAnchorOffset = ed.cursorOffset;
+ CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "only a BLOCK_PARAGRAPH can trigger, not e.g. a heading");
+ editor_free(&ed);
+
+ /* --- filter matching --- */
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ CHECK(block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], "", 0), "an empty filter matches every item");
+ }
+ int headingMatches = 0;
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ if (block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], "head", 4)) headingMatches++;
+ }
+ CHECK(headingMatches == 6, "\"head\" matches exactly the 6 heading items");
+ int junkMatches = 0;
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ if (block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], "xyz", 3)) junkMatches++;
+ }
+ CHECK(junkMatches == 0, "an unrelated filter matches nothing");
+
+ /* --- table invariants --- */
+ bool anyTaskChecked = false, hrOffered = true;
+ for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
+ if (BLOCK_TYPE_MENU_ITEMS[i].type == BLOCK_TASK_CHECKED) anyTaskChecked = true;
+ if (BLOCK_TYPE_MENU_ITEMS[i].type == BLOCK_HR) hrOffered = BLOCK_TYPE_MENU_ITEMS[i].offerInTurnInto;
+ }
+ CHECK(!anyTaskChecked, "no menu item targets BLOCK_TASK_CHECKED (a fresh task always starts unchecked)");
+ CHECK(!hrOffered, "BLOCK_HR is never offered by Turn Into (it can't preserve existing content)");
+
+ /* --- editor_quick_insert_apply --- */
+ EditorState ed2;
+ editor_init(&ed2);
+ type_str(&ed2, "@heading1");
+ editor_quick_insert_apply(&ed2, BLOCK_H1);
+ CHECK(ed2.doc.blocks[0].type == BLOCK_H1, "quick-insert converts the block to the chosen type");
+ CHECK(text_eq(&ed2.doc.blocks[0], ""), "quick-insert clears the '@word' text");
+ CHECK(ed2.cursorOffset == 0, "cursor lands at the start of the now-empty block");
+
+ sb_clear(&ed2.doc.blocks[0].text);
+ ed2.doc.blocks[0].type = BLOCK_PARAGRAPH;
+ ed2.cursorOffset = 0;
+ ed2.selAnchorBlock = ed2.cursorBlock; ed2.selAnchorOffset = ed2.cursorOffset;
+ type_str(&ed2, "@bullet");
+ editor_quick_insert_apply(&ed2, BLOCK_BULLET);
+ CHECK(ed2.doc.blocks[0].type == BLOCK_BULLET, "quick-insert -> bullet list");
+
+ sb_clear(&ed2.doc.blocks[0].text);
+ ed2.doc.blocks[0].type = BLOCK_PARAGRAPH;
+ ed2.cursorOffset = 0;
+ ed2.selAnchorBlock = ed2.cursorBlock; ed2.selAnchorOffset = ed2.cursorOffset;
+ type_str(&ed2, "@code");
+ editor_quick_insert_apply(&ed2, BLOCK_CODE);
+ CHECK(ed2.doc.blocks[0].type == BLOCK_CODE, "quick-insert -> code block");
+
+ int countBefore = ed2.doc.count;
+ sb_clear(&ed2.doc.blocks[0].text);
+ ed2.doc.blocks[0].type = BLOCK_PARAGRAPH;
+ ed2.cursorOffset = 0;
+ ed2.selAnchorBlock = ed2.cursorBlock; ed2.selAnchorOffset = ed2.cursorOffset;
+ type_str(&ed2, "@hr");
+ editor_quick_insert_apply(&ed2, BLOCK_HR);
+ CHECK(ed2.doc.blocks[0].type == BLOCK_HR, "quick-insert -> horizontal rule");
+ CHECK(ed2.doc.count == countBefore + 1, "a following empty paragraph is inserted after the new HR");
+ CHECK(ed2.doc.blocks[1].type == BLOCK_PARAGRAPH && text_eq(&ed2.doc.blocks[1], ""), "the following block is an empty paragraph");
+ CHECK(ed2.cursorBlock == 1 && ed2.cursorOffset == 0, "the cursor moves into the new empty paragraph");
+ editor_free(&ed2);
+
+ /* --- editor_turn_into_apply preserves content --- */
+ EditorState ed3;
+ editor_init(&ed3);
+ type_str(&ed3, "some real text");
+ editor_turn_into_apply(&ed3, BLOCK_H2);
+ CHECK(ed3.doc.blocks[0].type == BLOCK_H2, "turn-into converts the block to the chosen type");
+ CHECK(text_eq(&ed3.doc.blocks[0], "some real text"), "turn-into preserves the existing text");
+ editor_free(&ed3);
+
+ /* --- the critical regression: leaving BLOCK_CODE scrubs '\n' and clears lang --- */
+ EditorState ed4;
+ editor_init(&ed4);
+ type_str(&ed4, "```c");
+ editor_enter(&ed4);
+ type_str(&ed4, "line1");
+ editor_enter(&ed4); /* embeds a '\n', stays BLOCK_CODE (not a closing fence) */
+ type_str(&ed4, "line2");
+ CHECK(ed4.doc.blocks[0].type == BLOCK_CODE && ed4.doc.blocks[0].lang.len == 1, "multi-line code block with a language is set up");
+ bool hasNewline = false;
+ for (int i = 0; i < ed4.doc.blocks[0].text.len; i++) if (ed4.doc.blocks[0].text.data[i] == '\n') hasNewline = true;
+ CHECK(hasNewline, "the code block really does contain an embedded newline before conversion");
+
+ editor_turn_into_apply(&ed4, BLOCK_H3);
+ CHECK(ed4.doc.blocks[0].type == BLOCK_H3, "turn-into converts the (former) code block");
+ bool stillHasNewline = false;
+ for (int i = 0; i < ed4.doc.blocks[0].text.len; i++) if (ed4.doc.blocks[0].text.data[i] == '\n') stillHasNewline = true;
+ CHECK(!stillHasNewline, "the embedded newline is scrubbed to a space, since no other block renderer expects one");
+ CHECK(text_eq(&ed4.doc.blocks[0], "line1 line2"), "the scrubbed text is exactly the two lines joined by a space");
+ CHECK(ed4.doc.blocks[0].lang.len == 0, "lang is cleared when leaving BLOCK_CODE");
+ editor_free(&ed4);
+
+ /* --- undo/redo --- */
+ EditorState ed5;
+ editor_init(&ed5);
+ type_str(&ed5, "@quote");
+ editor_quick_insert_apply(&ed5, BLOCK_QUOTE);
+ CHECK(ed5.doc.blocks[0].type == BLOCK_QUOTE, "quick-insert applied");
+ CHECK(editor_undo(&ed5), "undo the quick-insert conversion");
+ CHECK(ed5.doc.blocks[0].type == BLOCK_PARAGRAPH && text_eq(&ed5.doc.blocks[0], "@quote"),
+ "undo restores the original paragraph with the '@word' text intact -- it does not step through the typing too");
+ CHECK(editor_undo(&ed5), "a second undo now reverts the coalesced typing");
+ CHECK(text_eq(&ed5.doc.blocks[0], ""), "back to the empty paragraph");
+ CHECK(editor_redo(&ed5) && editor_redo(&ed5), "redo replays both steps");
+ CHECK(ed5.doc.blocks[0].type == BLOCK_QUOTE, "redo ends back at the converted quote block");
+ editor_free(&ed5);
+
+ EditorState ed6;
+ editor_init(&ed6);
+ type_str(&ed6, "keep me");
+ editor_turn_into_apply(&ed6, BLOCK_BULLET);
+ CHECK(editor_undo(&ed6), "undo the turn-into conversion");
+ CHECK(ed6.doc.blocks[0].type == BLOCK_PARAGRAPH && text_eq(&ed6.doc.blocks[0], "keep me"),
+ "undo restores the original type with the text still intact");
+ editor_free(&ed6);
+}
+
int main(void) {
test_heading_autoformat();
test_bullet_list_continuation();
@@ -744,6 +898,7 @@ int main(void) {
test_code_fence_with_language();
test_inline_extended_styles();
test_inline_autolink();
+ test_block_type_menu();
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 dba8f8e..83bf3b6 100644
--- a/src/theme.h
+++ b/src/theme.h
@@ -79,4 +79,11 @@ int theme_scaled_font_size(int baseSize);
#define HR_THICKNESS 1
#define HR_TOP_PAD 14
+#define TURN_INTO_ICON_SIZE 22
+#define TURN_INTO_ICON_GAP 6
+#define TURN_INTO_MENU_WIDTH 190
+#define BLOCK_TYPE_MENU_ROW_HEIGHT 32
+#define BLOCK_TYPE_MENU_GAP 4
+#define QUICK_INSERT_MENU_WIDTH 220
+
#endif
diff --git a/src/undo.h b/src/undo.h
index e82f46a..9bd7ef6 100644
--- a/src/undo.h
+++ b/src/undo.h
@@ -23,7 +23,8 @@ 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, EDIT_TOGGLE_TASK } EditKind;
+typedef enum { EDIT_NONE, EDIT_TYPE, EDIT_BACKSPACE, EDIT_DELETE_FWD, EDIT_ENTER, EDIT_PASTE, EDIT_TOGGLE_TASK,
+ EDIT_QUICK_INSERT, EDIT_TURN_INTO } EditKind;
typedef struct {
SnapshotStack undo, redo;