commits
tags
#include "editor.h"
#include "markdown_io.h"
#include "hittest.h"
#include "utf8.h"
#include "lang.h"
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
static void reset_preferred_x(EditorState *ed) {
ed->preferredX = -1.0f;
}
/* Collapses the selection anchor onto the current cursor position (extend=false), or leaves
it untouched to extend the existing selection (extend=true). */
static void snap_anchor_to_cursor(EditorState *ed, bool extend) {
if (extend) return;
ed->selAnchorBlock = ed->cursorBlock;
ed->selAnchorOffset = ed->cursorOffset;
}
void editor_init(EditorState *ed) {
document_init(&ed->doc);
ed->cursorBlock = 0;
ed->cursorOffset = 0;
ed->preferredX = -1.0f;
ed->filePath = NULL;
ed->dirty = false;
undo_manager_init(&ed->undo, 200);
ed->selAnchorBlock = 0;
ed->selAnchorOffset = 0;
}
void editor_free(EditorState *ed) {
document_free(&ed->doc);
free(ed->filePath);
ed->filePath = NULL;
undo_manager_free(&ed->undo);
}
bool editor_load(EditorState *ed, const char *path) {
free(ed->filePath);
size_t n = strlen(path);
ed->filePath = malloc(n + 1);
memcpy(ed->filePath, path, n + 1);
bool ok = document_load_file(&ed->doc, path);
ed->cursorBlock = 0;
ed->cursorOffset = 0;
ed->dirty = false;
undo_manager_reset(&ed->undo);
ed->selAnchorBlock = 0;
ed->selAnchorOffset = 0;
reset_preferred_x(ed);
return ok;
}
bool editor_save(EditorState *ed) {
if (!ed->filePath) return false;
bool ok = document_save_file(&ed->doc, ed->filePath);
if (ok) ed->dirty = false;
return ok;
}
const char *editor_display_name(const EditorState *ed) {
if (!ed->filePath) return lang_get(STR_UNTITLED);
const char *slash = strrchr(ed->filePath, '/');
return slash ? slash + 1 : ed->filePath;
}
/* Converts a leading run of paragraph text (e.g. "#", "##", "-", "1.") typed just before the
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];
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;
if (upto == 3 && (memcmp(t, "---", 3) == 0 || memcmp(t, "***", 3) == 0 || memcmp(t, "___", 3) == 0)) {
sb_delete(&b->text, 0, ed->cursorOffset);
ed->cursorOffset = 0;
b->type = BLOCK_HR;
document_insert_block(&ed->doc, ed->cursorBlock + 1, BLOCK_PARAGRAPH, "", 0);
ed->cursorBlock++;
ed->cursorOffset = 0;
return;
}
BlockType newType;
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])) {
int i = 0;
while (i < upto && isdigit((unsigned char)t[i])) i++;
if (i < upto && t[i] == '.' && i + 1 == upto) newType = BLOCK_NUMBERED;
else return;
} else {
return;
}
sb_delete(&b->text, 0, ed->cursorOffset);
ed->cursorOffset = 0;
b->type = newType;
}
bool editor_has_selection(const EditorState *ed) {
return ed->selAnchorBlock != ed->cursorBlock || ed->selAnchorOffset != ed->cursorOffset;
}
void editor_selection_range(const EditorState *ed, int *startBlock, int *startOffset, int *endBlock, int *endOffset) {
int ab = ed->selAnchorBlock, ao = ed->selAnchorOffset, cb = ed->cursorBlock, co = ed->cursorOffset;
if (ab < cb || (ab == cb && ao <= co)) {
*startBlock = ab; *startOffset = ao; *endBlock = cb; *endOffset = co;
} else {
*startBlock = cb; *startOffset = co; *endBlock = ab; *endOffset = ao;
}
}
/* If a selection is active, deletes it (merging a cross-block selection into the start
block, which keeps its type) and collapses the cursor/anchor to the deletion point.
Returns whether there was a selection to delete. */
static bool delete_selection_if_any(EditorState *ed) {
if (!editor_has_selection(ed)) return false;
int sb, so, eb, eo;
editor_selection_range(ed, &sb, &so, &eb, &eo);
bool spansTableCell = false;
for (int i = sb; i <= eb; i++) {
if (block_type_is_table_cell(ed->doc.blocks[i].type)) { spansTableCell = true; break; }
}
if (spansTableCell) {
/* Never merge text across cells or remove a cell block here -- that would corrupt the
table's fixed rows*cols run (tableCol/tableCols arithmetic in rendering and
vertical-nav trusts a clean, unbroken sequence downstream). Clear each selected
block's own covered range in place instead -- for a same-cell selection (sb == eb)
this is identical to the plain-text path below; it only changes behavior once the
selection actually spans more than one block. */
for (int i = sb; i <= eb; i++) {
Block *b = &ed->doc.blocks[i];
int start = (i == sb) ? so : 0;
int end = (i == eb) ? eo : b->text.len;
sb_delete(&b->text, start, end - start);
}
} else if (sb == eb) {
sb_delete(&ed->doc.blocks[sb].text, so, eo - so);
} else {
Block *startBlock = &ed->doc.blocks[sb];
Block *endBlock = &ed->doc.blocks[eb];
sb_delete(&startBlock->text, so, startBlock->text.len - so);
sb_append(&startBlock->text, endBlock->text.data + eo, endBlock->text.len - eo);
for (int i = eb; i > sb; i--) document_remove_block(&ed->doc, i);
}
ed->cursorBlock = ed->selAnchorBlock = sb;
ed->cursorOffset = ed->selAnchorOffset = so;
ed->dirty = true;
reset_preferred_x(ed);
return true;
}
char *editor_selection_to_text(const EditorState *ed, int *outLen) {
if (!editor_has_selection(ed)) { if (outLen) *outLen = 0; return NULL; }
int sb, so, eb, eo;
editor_selection_range(ed, &sb, &so, &eb, &eo);
StrBuf out;
sb_init(&out);
for (int i = sb; i <= eb; i++) {
Block *b = &ed->doc.blocks[i];
int start = (i == sb) ? so : 0;
int end = (i == eb) ? eo : b->text.len;
if (i > sb) sb_append_char(&out, '\n');
sb_append(&out, b->text.data + start, end - start);
}
char *result = malloc((size_t)out.len + 1);
memcpy(result, out.data, (size_t)out.len);
result[out.len] = '\0';
if (outLen) *outLen = out.len;
sb_free(&out);
return result;
}
static void insert_utf8_raw(EditorState *ed, const char *bytes, int n) {
Block *b = &ed->doc.blocks[ed->cursorBlock];
sb_insert(&b->text, ed->cursorOffset, bytes, n);
ed->cursorOffset += n;
ed->dirty = true;
if (n == 1 && bytes[0] == ' ') maybe_autoformat_block(ed);
reset_preferred_x(ed);
}
void editor_insert_utf8(EditorState *ed, const char *bytes, int n) {
if (n <= 0) return;
undo_manager_before_edit(&ed->undo, EDIT_TYPE, &ed->doc, ed->cursorBlock, ed->cursorOffset);
delete_selection_if_any(ed);
insert_utf8_raw(ed, bytes, n);
snap_anchor_to_cursor(ed, false);
undo_manager_after_edit(&ed->undo, EDIT_TYPE, ed->cursorBlock, ed->cursorOffset);
}
void editor_paste(EditorState *ed, const char *text, int len) {
if (len <= 0) return;
char *tmp = malloc((size_t)len);
int n = 0;
for (int i = 0; i < len; i++) {
char c = text[i];
if (c == '\r') continue;
if (c == '\n' || c == '\t') c = ' ';
if ((unsigned char)c < 0x20) continue;
tmp[n++] = c;
}
if (n > 0) {
undo_manager_before_edit(&ed->undo, EDIT_PASTE, &ed->doc, ed->cursorBlock, ed->cursorOffset);
delete_selection_if_any(ed);
insert_utf8_raw(ed, tmp, n);
snap_anchor_to_cursor(ed, false);
undo_manager_after_edit(&ed->undo, EDIT_PASTE, ed->cursorBlock, ed->cursorOffset);
}
free(tmp);
}
/* Removes every block belonging to the table that starts at `headerFirstIdx` (the header row's
first cell), replacing them all with a single empty BLOCK_PARAGRAPH at that position. This is
the one deliberate, discoverable way to remove a whole table (mirrors BLOCK_HR's
demote-then-remove precedent) -- needed because every other deleting gesture is guarded to
never touch a table's cell blocks at all. */
static void remove_table_at(EditorState *ed, int headerFirstIdx) {
int cols = ed->doc.blocks[headerFirstIdx].tableCols;
int tableEnd = headerFirstIdx;
while (tableEnd < ed->doc.count && block_type_is_table_cell(ed->doc.blocks[tableEnd].type)
&& ed->doc.blocks[tableEnd].tableCols == cols) {
tableEnd++;
}
for (int i = tableEnd - 1; i >= headerFirstIdx; i--) document_remove_block(&ed->doc, i);
document_insert_block(&ed->doc, headerFirstIdx, BLOCK_PARAGRAPH, "", 0);
ed->cursorBlock = headerFirstIdx;
ed->cursorOffset = 0;
}
static void backspace_raw(EditorState *ed) {
Block *b = &ed->doc.blocks[ed->cursorBlock];
if (ed->cursorOffset > 0) {
int prevStart = utf8_prev_start(b->text.data, ed->cursorOffset);
sb_delete(&b->text, prevStart, ed->cursorOffset - prevStart);
ed->cursorOffset = prevStart;
ed->dirty = true;
} else if (block_type_is_table_cell(b->type)) {
/* Checked before the generic b->type != BLOCK_PARAGRAPH demote branch below, or a cell
would silently become an orphaned paragraph via that existing path. No-op at any cell
except the table's very first (tableCol == 0 of the header row), which removes the
whole table -- never merge into the preceding block, that would smash two cells' (or
a cell and unrelated) text together across a structural boundary. */
if (b->type == BLOCK_TABLE_HEADER_CELL && b->tableCol == 0) {
remove_table_at(ed, ed->cursorBlock);
ed->dirty = true;
}
} else if (b->type != BLOCK_PARAGRAPH) {
if (b->type == BLOCK_CODE) {
for (int i = 0; i < b->text.len; i++) if (b->text.data[i] == '\n') b->text.data[i] = ' ';
}
if (b->type == BLOCK_IMAGE) {
sb_clear(&b->alt);
}
b->type = BLOCK_PARAGRAPH;
ed->dirty = true;
} else if (ed->cursorBlock > 0 && !block_type_is_table_cell(ed->doc.blocks[ed->cursorBlock - 1].type)) {
/* Symmetric guard: never merge an ordinary block's text into a preceding table cell
either -- same principle, just the other direction. */
Block *prev = &ed->doc.blocks[ed->cursorBlock - 1];
int mergeAt = prev->text.len;
sb_append(&prev->text, b->text.data, b->text.len);
document_remove_block(&ed->doc, ed->cursorBlock);
ed->cursorBlock--;
ed->cursorOffset = mergeAt;
ed->dirty = true;
}
reset_preferred_x(ed);
}
void editor_backspace(EditorState *ed) {
undo_manager_before_edit(&ed->undo, EDIT_BACKSPACE, &ed->doc, ed->cursorBlock, ed->cursorOffset);
if (delete_selection_if_any(ed)) {
undo_manager_after_edit(&ed->undo, EDIT_BACKSPACE, ed->cursorBlock, ed->cursorOffset);
return;
}
backspace_raw(ed);
snap_anchor_to_cursor(ed, false);
undo_manager_after_edit(&ed->undo, EDIT_BACKSPACE, ed->cursorBlock, ed->cursorOffset);
}
static void delete_forward_raw(EditorState *ed) {
Block *b = &ed->doc.blocks[ed->cursorBlock];
if (ed->cursorOffset < b->text.len) {
int nlen = utf8_next_len(b->text.data, ed->cursorOffset, b->text.len);
sb_delete(&b->text, ed->cursorOffset, nlen);
ed->dirty = true;
} else if (ed->cursorBlock < ed->doc.count - 1 && !block_type_is_table_cell(b->type)
&& !block_type_is_table_cell(ed->doc.blocks[ed->cursorBlock + 1].type)) {
/* Guarded both ways: if the current block is a table cell, merging the next block into
it would smash foreign text into a cell; if the *next* block is a table cell (even
though the current one isn't), merging it away would shrink that row out from under
the fixed tableCols arithmetic everything downstream trusts. Both are no-ops. */
Block *next = &ed->doc.blocks[ed->cursorBlock + 1];
if (b->type == BLOCK_CODE && next->type != BLOCK_CODE) {
sb_append_char(&b->text, '\n');
} else if (next->type == BLOCK_CODE && b->type != BLOCK_CODE) {
for (int i = 0; i < next->text.len; i++) if (next->text.data[i] == '\n') next->text.data[i] = ' ';
}
sb_append(&b->text, next->text.data, next->text.len);
document_remove_block(&ed->doc, ed->cursorBlock + 1);
ed->dirty = true;
}
reset_preferred_x(ed);
}
void editor_delete_forward(EditorState *ed) {
undo_manager_before_edit(&ed->undo, EDIT_DELETE_FWD, &ed->doc, ed->cursorBlock, ed->cursorOffset);
if (delete_selection_if_any(ed)) {
undo_manager_after_edit(&ed->undo, EDIT_DELETE_FWD, ed->cursorBlock, ed->cursorOffset);
return;
}
delete_forward_raw(ed);
snap_anchor_to_cursor(ed, false);
undo_manager_after_edit(&ed->undo, EDIT_DELETE_FWD, ed->cursorBlock, ed->cursorOffset);
}
static void enter_raw(EditorState *ed) {
Block *b = &ed->doc.blocks[ed->cursorBlock];
if (b->type == BLOCK_CODE) {
int lineStart = ed->cursorOffset;
while (lineStart > 0 && b->text.data[lineStart - 1] != '\n') lineStart--;
int lineEnd = ed->cursorOffset;
while (lineEnd < b->text.len && b->text.data[lineEnd] != '\n') lineEnd++;
bool closingFence = (lineEnd - lineStart == 3 && memcmp(b->text.data + lineStart, "```", 3) == 0);
/* Also exit on Enter-on-an-already-blank-trailing-line ("double Enter"), mirroring the
list-exit precedent below (block_type_is_continuable + empty text -> demote). This is
the only *reliable* keyboard-only way to leave a code block: producing a literal
backtick to type a real closing fence is a dead-key combination on some layouts (e.g.
Swedish, ` + space) that's easy to get stuck on, exactly like the already-documented
Ctrl+/ layout gap elsewhere in this codebase -- don't make escaping a code block
depend on it too. b->text.len > 0 excludes a just-created empty code block, so a
single Enter there still just adds a blank first line rather than immediately exiting. */
bool doubleEnterExit = (lineStart == lineEnd && ed->cursorOffset == b->text.len && b->text.len > 0);
if (closingFence || doubleEnterExit) {
int removeFrom = lineStart > 0 ? lineStart - 1 : lineStart;
sb_delete(&b->text, removeFrom, b->text.len - removeFrom);
document_insert_block(&ed->doc, ed->cursorBlock + 1, BLOCK_PARAGRAPH, "", 0);
ed->cursorBlock++;
ed->cursorOffset = 0;
ed->dirty = true;
reset_preferred_x(ed);
return;
}
sb_insert(&b->text, ed->cursorOffset, "\n", 1);
ed->cursorOffset++;
ed->dirty = true;
reset_preferred_x(ed);
return;
}
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;
ed->dirty = true;
reset_preferred_x(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;
reset_preferred_x(ed);
return;
}
if (b->type == BLOCK_IMAGE) {
/* text is the image's url -- never split/truncate it, regardless of cursor position in
the caption. Mirrors the BLOCK_HR-insertion pattern above: always land in a fresh
empty paragraph right after it. */
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_table_cell(b->type)) {
/* A cell's text is one line of GFM table syntax -- a literal '\n' would break it.
Enter behaves like Tab instead: move to the next cell, or exit the table. */
editor_table_move_cell(ed, 1);
ed->dirty = true;
return;
}
int splitAt = ed->cursorOffset;
int tailLen = b->text.len - splitAt;
BlockType newType = block_type_is_continuable(b->type) ? b->type : BLOCK_PARAGRAPH;
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 */
sb_delete(&b->text, splitAt, tailLen);
ed->cursorBlock++;
ed->cursorOffset = 0;
ed->dirty = true;
reset_preferred_x(ed);
}
void editor_enter(EditorState *ed) {
undo_manager_before_edit(&ed->undo, EDIT_ENTER, &ed->doc, ed->cursorBlock, ed->cursorOffset);
delete_selection_if_any(ed);
enter_raw(ed);
snap_anchor_to_cursor(ed, false);
undo_manager_after_edit(&ed->undo, EDIT_ENTER, ed->cursorBlock, ed->cursorOffset);
}
void editor_move_left(EditorState *ed, bool extend) {
if (ed->cursorOffset > 0) {
Block *b = &ed->doc.blocks[ed->cursorBlock];
ed->cursorOffset = utf8_prev_start(b->text.data, ed->cursorOffset);
} else if (ed->cursorBlock > 0) {
ed->cursorBlock--;
ed->cursorOffset = ed->doc.blocks[ed->cursorBlock].text.len;
}
snap_anchor_to_cursor(ed, extend);
reset_preferred_x(ed);
}
void editor_move_right(EditorState *ed, bool extend) {
Block *b = &ed->doc.blocks[ed->cursorBlock];
if (ed->cursorOffset < b->text.len) {
ed->cursorOffset += utf8_next_len(b->text.data, ed->cursorOffset, b->text.len);
} else if (ed->cursorBlock < ed->doc.count - 1) {
ed->cursorBlock++;
ed->cursorOffset = 0;
}
snap_anchor_to_cursor(ed, extend);
reset_preferred_x(ed);
}
void editor_move_home(EditorState *ed, bool extend) {
ed->cursorOffset = 0;
snap_anchor_to_cursor(ed, extend);
reset_preferred_x(ed);
}
void editor_move_end(EditorState *ed, bool extend) {
ed->cursorOffset = ed->doc.blocks[ed->cursorBlock].text.len;
snap_anchor_to_cursor(ed, extend);
reset_preferred_x(ed);
}
void editor_move_vertical(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts, int dir, bool extend) {
Block *cur = &ed->doc.blocks[ed->cursorBlock];
if (block_type_is_table_cell(cur->type)) {
/* hittest_vertical's curLine +/- 1 is a flat, declaration-order step through
cache->lines -- correct for ordinary blocks (each produces a vertically-stacked
sequence of lines) but wrong for a grid: cells are declared row-by-row left-to-right,
so stepping one adjacent line index from a cell would move to the cell to the right,
not the one below. Jump directly to the same column in the next/previous row instead. */
if (ed->preferredX < 0.0f) {
float x, y, h;
if (hittest_caret(cache, &ed->doc, fonts, ed->cursorBlock, ed->cursorOffset, &x, &y, &h)) {
ed->preferredX = x;
} else {
ed->preferredX = 0.0f;
}
}
int target = ed->cursorBlock + dir * cur->tableCols;
if (target >= 0 && target < ed->doc.count && block_type_is_table_cell(ed->doc.blocks[target].type)
&& ed->doc.blocks[target].tableCols == cur->tableCols) {
ed->cursorBlock = target;
ed->cursorOffset = hittest_offset_for_block_x(cache, &ed->doc, fonts, target, ed->preferredX);
snap_anchor_to_cursor(ed, extend);
return;
}
/* Stepped off the top/bottom of the table -- fall through to the normal path below,
which correctly steps to whatever non-table block precedes/follows it. */
}
if (ed->preferredX < 0.0f) {
float x, y, h;
if (hittest_caret(cache, &ed->doc, fonts, ed->cursorBlock, ed->cursorOffset, &x, &y, &h)) {
ed->preferredX = x;
} else {
ed->preferredX = 0.0f;
}
}
VerticalMoveResult r = hittest_vertical(cache, &ed->doc, fonts, ed->cursorBlock, ed->cursorOffset, ed->preferredX, dir);
if (r.found) {
ed->cursorBlock = r.blockIndex;
ed->cursorOffset = r.offset;
}
snap_anchor_to_cursor(ed, extend);
}
void editor_click(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts, Clay_Vector2 point) {
HitResult r = hittest_point(cache, &ed->doc, fonts, point);
ed->cursorBlock = r.blockIndex;
ed->cursorOffset = r.offset;
ed->selAnchorBlock = r.blockIndex;
ed->selAnchorOffset = r.offset;
reset_preferred_x(ed);
}
void editor_drag_to(EditorState *ed, LayoutCache *cache, SDL2_Font *fonts, Clay_Vector2 point) {
HitResult r = hittest_point(cache, &ed->doc, fonts, point);
ed->cursorBlock = r.blockIndex;
ed->cursorOffset = r.offset;
reset_preferred_x(ed);
}
bool editor_undo(EditorState *ed) {
Snapshot s;
if (!snapshot_stack_pop(&ed->undo.undo, &s)) return false;
snapshot_stack_push(&ed->undo.redo, &ed->doc, ed->cursorBlock, ed->cursorOffset);
document_free(&ed->doc);
ed->doc = s.doc;
ed->cursorBlock = s.cursorBlock;
ed->cursorOffset = s.cursorOffset;
ed->selAnchorBlock = s.cursorBlock;
ed->selAnchorOffset = s.cursorOffset;
ed->dirty = true;
ed->undo.lastKind = EDIT_NONE;
reset_preferred_x(ed);
return true;
}
bool editor_redo(EditorState *ed) {
Snapshot s;
if (!snapshot_stack_pop(&ed->undo.redo, &s)) return false;
snapshot_stack_push(&ed->undo.undo, &ed->doc, ed->cursorBlock, ed->cursorOffset);
document_free(&ed->doc);
ed->doc = s.doc;
ed->cursorBlock = s.cursorBlock;
ed->cursorOffset = s.cursorOffset;
ed->selAnchorBlock = s.cursorBlock;
ed->selAnchorOffset = s.cursorOffset;
ed->dirty = true;
ed->undo.lastKind = EDIT_NONE;
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;
}
/* 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 (b->type == BLOCK_IMAGE && newType != BLOCK_IMAGE) {
sb_clear(&b->alt);
}
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);
}
void editor_table_move_cell(EditorState *ed, int dir) {
Block *b = &ed->doc.blocks[ed->cursorBlock];
if (!block_type_is_table_cell(b->type)) return;
int target = ed->cursorBlock + dir;
if (target >= 0 && target < ed->doc.count && block_type_is_table_cell(ed->doc.blocks[target].type)) {
ed->cursorBlock = target;
ed->cursorOffset = 0;
} else if (dir > 0) {
if (target < ed->doc.count) {
ed->cursorBlock = target;
} else {
document_insert_block(&ed->doc, ed->doc.count, BLOCK_PARAGRAPH, "", 0);
ed->cursorBlock = ed->doc.count - 1;
ed->dirty = true;
}
ed->cursorOffset = 0;
} else if (target >= 0) {
ed->cursorBlock = target;
ed->cursorOffset = 0;
}
/* else: Shift+Tab from the table's very first cell with nothing before it -- no-op. */
snap_anchor_to_cursor(ed, false);
reset_preferred_x(ed);
}
void editor_insert_table(EditorState *ed, int rows, int cols) {
undo_manager_before_edit(&ed->undo, EDIT_INSERT_TABLE, &ed->doc, ed->cursorBlock, ed->cursorOffset);
/* Like editor_quick_insert_apply, this is only ever called on the bare "@word" the user just
typed (see the Table sentinel handling in main.c) -- always replace ed->cursorBlock,
discarding whatever it held, rather than only when it happens to already be empty. */
int insertAt = ed->cursorBlock;
document_remove_block(&ed->doc, ed->cursorBlock);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
BlockType type = (r == 0) ? BLOCK_TABLE_HEADER_CELL : BLOCK_TABLE_CELL;
int idx = insertAt + r * cols + c;
document_insert_block(&ed->doc, idx, type, "", 0);
Block *cell = &ed->doc.blocks[idx];
cell->tableCol = c;
cell->tableCols = cols;
cell->tableAlign = 'l';
}
}
ed->cursorBlock = insertAt;
ed->cursorOffset = 0;
ed->dirty = true;
snap_anchor_to_cursor(ed, false);
undo_manager_after_edit(&ed->undo, EDIT_INSERT_TABLE, ed->cursorBlock, ed->cursorOffset);
}