foxygit / Hush Log in
commits tags

/src/main.c · 25.36 KB

raw
#include <SDL.h>
#include <SDL_ttf.h>
#include "clay.h"
#include "editor.h"
#include "render.h"
#include "layout_cache.h"
#include "image_cache.h"
#include "fonts.h"
#include "theme.h"
#include "app_mode.h"
#include "block_type_menu.h"
#include "config.h"
#include "keymap.h"
#include "lang.h"
#include "paths.h"
#include "utf8.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>

static void handle_clay_errors(Clay_ErrorData errorData) {
    fprintf(stderr, "Clay error: %s\n", errorData.errorText.chars);
}

static double now_seconds(void) {
    return (double)SDL_GetPerformanceCounter() / (double)SDL_GetPerformanceFrequency();
}

static void update_window_title(SDL_Window *window, EditorState *ed) {
    char title[512];
    snprintf(title, sizeof title, "%s%s \xe2\x80\x94 hush", ed->dirty ? "*" : "", editor_display_name(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);
    char *copy = malloc(n + 1);
    memcpy(copy, s, n + 1);
    return copy;
}

/* Tries to load OpenPromptState's typed path. On success, switches back to MODE_EDITING and
   updates the window title (the dirty-flag-change hook that normally does this won't fire here,
   since editor_load always resets dirty to false regardless of whether it came in already
   false). On failure, restores the previous filePath (editor_load overwrites it unconditionally,
   even on a failed read) so a stray Ctrl+S afterward can't silently save over the wrong name,
   and leaves `prompt` and `mode` alone so the user can see the error and correct the path. */
static void try_open_from_prompt(SDL_Window *window, EditorState *ed, OpenPromptState *prompt, AppMode *mode) {
    if (prompt->len == 0) return;
    char *oldPath = dup_str_or_null(ed->filePath);
    if (editor_load(ed, prompt->path)) {
        free(oldPath);
        prompt->showError = false;
        *mode = MODE_EDITING;
        update_window_title(window, ed);
    } else {
        free(ed->filePath);
        ed->filePath = oldPath;
        prompt->showError = true;
    }
}

/* This frame's SDL events, drained once at the top of the loop. Continuous "is held" state
   (arrow/backspace repeat, modifier keys, mouse position) is read live via
   SDL_GetKeyboardState/SDL_GetModState/SDL_GetMouseState instead of being buffered here --
   only discrete, easy-to-miss-if-polled-later events need collecting. */
typedef struct {
    bool quit;
    char textInput[256];
    int textInputLen;
    bool mouseLeftPressed, mouseLeftReleased;
    float wheelX, wheelY;
} FrameInput;

static void poll_frame_input(FrameInput *fi) {
    memset(fi, 0, sizeof *fi);
    keymap_begin_frame();
    SDL_Event e;
    while (SDL_PollEvent(&e)) {
        switch (e.type) {
            case SDL_QUIT:
                fi->quit = true;
                break;
            case SDL_KEYDOWN:
                /* SDL's own OS-level auto-repeat would otherwise fire SDL_KEYDOWN repeatedly for
                   a held chord (e.g. Ctrl+S), which keymap_pressed's "just pressed" semantics
                   must not see -- unlike raylib's IsKeyPressed, SDL doesn't filter this for us. */
                if (!e.key.repeat) keymap_note_keydown(e.key.keysym.sym);
                break;
            case SDL_TEXTINPUT: {
                size_t n = strlen(e.text.text);
                if (fi->textInputLen + (int)n < (int)sizeof fi->textInput) {
                    memcpy(fi->textInput + fi->textInputLen, e.text.text, n);
                    fi->textInputLen += (int)n;
                }
                break;
            }
            case SDL_MOUSEBUTTONDOWN:
                if (e.button.button == SDL_BUTTON_LEFT) fi->mouseLeftPressed = true;
                break;
            case SDL_MOUSEBUTTONUP:
                if (e.button.button == SDL_BUTTON_LEFT) fi->mouseLeftReleased = true;
                break;
            case SDL_MOUSEWHEEL: {
                float mult = (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) ? -1.0f : 1.0f;
                fi->wheelX += mult * (float)e.wheel.x;
                fi->wheelY += mult * (float)e.wheel.y;
                break;
            }
            default: break;
        }
    }
}

int main(int argc, char **argv) {
    paths_init();
    Config cfg;
    config_load(&cfg); /* pure file I/O -- safe to run before window init */
    lang_load(cfg.language);
    theme_init_defaults();
    g_theme.bg = cfg.bgColor;
    g_theme.text = cfg.textColor;
    g_theme.cursor = cfg.textColor; /* caret always tracks the configured text color */
    g_theme.zoom = cfg.zoom;
    memcpy(g_keymap, cfg.keymap, sizeof g_keymap);

    SDL_Init(SDL_INIT_VIDEO);
    TTF_Init();
    /* SDL_WINDOW_ALLOW_HIGHDPI deliberately omitted: raylib's equivalent flag caused a broken
       viewport on this project's actual mixed-DPI X11 setup earlier and was removed -- don't
       re-risk the same bug speculatively. */
    SDL_Window *window = SDL_CreateWindow("hush", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
                                           1000, 800, SDL_WINDOW_RESIZABLE);
    SDL_Renderer *renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
    if (!renderer) renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_SOFTWARE);
    /* SDL_Renderer defaults to SDL_BLENDMODE_NONE (alpha ignored, drawn fully opaque) -- without
       this, every semi-transparent Clay_Color (the modal scrim, selection highlight, ==highlight==
       spans) would render fully opaque instead of blending with what's underneath. */
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
    SDL_StartTextInput();

    int winWi, winHi;
    SDL_GetWindowSize(window, &winWi, &winHi);

    uint64_t memSize = Clay_MinMemorySize();
    Clay_Arena arena = Clay_CreateArenaWithCapacityAndMemory(memSize, malloc(memSize));
    Clay_Initialize(arena, (Clay_Dimensions){ (float)winWi, (float)winHi }, (Clay_ErrorHandler){ handle_clay_errors, 0 });

    SDL2_Font fonts[FONT_COUNT];
    fonts_load(fonts, cfg.fontPaths);
    config_free(&cfg);
    Clay_SetMeasureTextFunction(hush_measure_text, fonts);

    EditorState ed;
    editor_init(&ed);
    if (argc >= 2) {
        editor_load(&ed, argv[1]);
    }
    update_window_title(window, &ed);

    LayoutCache cache;
    layout_cache_init(&cache);

    ImageCache *imageCache;
    image_cache_init(&imageCache);

    double now0 = now_seconds();
    double blinkResetTime = now0;
    double lastFrameNow = now0;
    int lastBlock = ed.cursorBlock, lastOffset = ed.cursorOffset;
    bool lastDirty = ed.dirty;

    double repLeft = 0, repRight = 0, repUp = 0, repDown = 0, repBackspace = 0, repDelete = 0;
    double repZoomIn = 0, repZoomOut = 0, repZoomInKp = 0, repZoomOutKp = 0;
    double repOpenBackspace = 0;

    bool windowClosing = false;
    AppMode mode = MODE_EDITING;
    OpenPromptState openPrompt = { .path = "", .len = 0, .showError = false };
    /* True if the MODE_QUIT_CONFIRM currently showing is guarding Ctrl+O (unsaved changes
       before opening another file) rather than Ctrl+X/window-close (quitting outright). */
    bool confirmForOpen = false;
    /* True only for a mouse-down session whose *press* frame was itself handled in
       MODE_EDITING -- prevents the tail end of a click that dismissed a modal (still
       physically held down for a frame or two afterward) from being replayed as a
       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);
        lastFrameNow = now;

        FrameInput fi;
        poll_frame_input(&fi);

        SDL_Keymod mods = SDL_GetModState();
        bool ctrl = (mods & KMOD_CTRL) != 0;
        bool shift = (mods & KMOD_SHIFT) != 0;
        bool alt = (mods & KMOD_ALT) != 0;

        int mx, my;
        Uint32 mouseButtons = SDL_GetMouseState(&mx, &my);
        bool mouseLeftDown = (mouseButtons & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0;
        Clay_Vector2 mousePos = { (float)mx, (float)my };

        if (mode == MODE_EDITING) {
            if (keymap_pressed(ACTION_SAVE, ctrl, shift, alt)) {
                editor_save(&ed);
            } else if (keymap_pressed(ACTION_OPEN, ctrl, shift, alt)) {
                if (ed.dirty) {
                    confirmForOpen = true;
                    mode = MODE_QUIT_CONFIRM;
                } else {
                    openPrompt.len = 0;
                    openPrompt.path[0] = '\0';
                    openPrompt.showError = false;
                    mode = MODE_OPEN_PROMPT;
                }
            } else if (keymap_pressed(ACTION_PASTE, ctrl, shift, alt)) {
                char *clip = SDL_GetClipboardText();
                if (clip) { editor_paste(&ed, clip, (int)strlen(clip)); SDL_free(clip); }
            } else if (keymap_pressed(ACTION_COPY, ctrl, shift, alt)) {
                int len;
                char *text = editor_selection_to_text(&ed, &len);
                if (text) { SDL_SetClipboardText(text); free(text); }
            } else if (keymap_pressed(ACTION_REDO, ctrl, shift, alt)) {
                editor_redo(&ed);
            } else if (keymap_pressed(ACTION_UNDO, ctrl, shift, alt)) {
                editor_undo(&ed);
            } else if (keymap_pressed(ACTION_QUIT, ctrl, shift, alt)) {
                confirmForOpen = false;
                mode = ed.dirty ? MODE_QUIT_CONFIRM : MODE_EDITING;
                if (!ed.dirty) windowClosing = true;
            } else if (keymap_pressed(ACTION_HELP, ctrl, shift, alt) || keymap_key_pressed_raw(SDLK_F1)) {
                /* ACTION_HELP defaults to Ctrl+/. SDL_Keycode fixes the raylib-era layout problem
                   for keys whose base/unshifted level matches (letters, digits, and symbols that
                   sit unshifted on the current layout) -- but SDL2's `.sym` only ever reports a
                   key's *unshifted* level, even when shift is actually held, and SDL_TEXTINPUT
                   (which does resolve the shifted character) is suppressed entirely while Ctrl is
                   down. So on a layout where producing "/" itself requires Shift (e.g. Swedish,
                   Shift+7), there is no public SDL2 keycode this can bind to -- a real SDL2 API
                   gap (reportedly addressed by SDL3's modifier-aware key queries), not something
                   fixable here without adding a raw XKB dependency. F1 stays as a fixed,
                   always-reachable fallback for exactly this reason. */
                mode = MODE_SHORTCUTS_HELP;
            } else if (keymap_pressed(ACTION_SOURCE_VIEW, ctrl, shift, alt)) {
                mode = MODE_SOURCE_VIEW;
            } else if (keymap_repeat(ACTION_ZOOM_IN, ctrl, shift, alt, now, &repZoomIn) ||
                       (ctrl && keymap_repeat_scancode(SDL_SCANCODE_KP_PLUS, now, &repZoomInKp))) {
                /* ACTION_ZOOM_IN defaults to Ctrl++ rather than Ctrl+= specifically because "+"
                   sits unshifted on this layout (Swedish: "0"/"+"/"?" share a key, "+" at the
                   base level, unlike "=" which needs Shift+0) -- so it doesn't hit the SDL2
                   keycode gap described for ACTION_HELP above. KP_PLUS (numpad +) is a fixed
                   fallback for keyboards with a numpad, regardless of what ACTION_ZOOM_IN is
                   rebound to. */
                theme_zoom_in();
            } else if (keymap_repeat(ACTION_ZOOM_OUT, ctrl, shift, alt, now, &repZoomOut) ||
                       (ctrl && keymap_repeat_scancode(SDL_SCANCODE_KP_MINUS, now, &repZoomOutKp))) {
                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);
                }

                /* 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++;
                            }
                            /* BLOCK_TABLE_HEADER_CELL doubles as the Table row's sentinel --
                               inserting a table means creating rows*cols new blocks, which
                               doesn't fit editor_quick_insert_apply's single-block-type-change
                               signature, so it's intercepted here rather than passed through. */
                            if (chosenType == BLOCK_TABLE_HEADER_CELL) {
                                editor_insert_table(&ed, 2, 2);
                            } else {
                                editor_quick_insert_apply(&ed, chosenType);
                            }
                            quickInsertDismissed = true;
                        }
                    }
                    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_key_pressed_raw(SDLK_HOME)) editor_move_home(&ed, shift);
                if (keymap_key_pressed_raw(SDLK_END)) editor_move_end(&ed, shift);
                if (block_type_is_table_cell(ed.doc.blocks[ed.cursorBlock].type) && keymap_key_pressed_raw(SDLK_TAB)) {
                    editor_table_move_cell(&ed, shift ? -1 : 1);
                }
            }

            bool overTurnIntoUi = point_in_element(CLAY_ID("TurnIntoIcon"), mousePos)
                               || (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 && !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(). */
            if (fi.quit) {
                confirmForOpen = false;
                mode = ed.dirty ? MODE_QUIT_CONFIRM : MODE_EDITING;
                if (!ed.dirty) windowClosing = true;
            }
        } else if (mode == MODE_QUIT_CONFIRM) {
            if (keymap_key_pressed_raw(SDLK_ESCAPE)) mode = MODE_EDITING;
        } else if (mode == MODE_SHORTCUTS_HELP) {
            if (keymap_key_pressed_raw(SDLK_ESCAPE) || keymap_key_pressed_raw(SDLK_F1) || keymap_pressed(ACTION_HELP, ctrl, shift, alt)) mode = MODE_EDITING;
        } else if (mode == MODE_SOURCE_VIEW) {
            if (keymap_key_pressed_raw(SDLK_ESCAPE) || keymap_pressed(ACTION_SOURCE_VIEW, ctrl, shift, alt)) mode = MODE_EDITING;
        } else if (mode == MODE_OPEN_PROMPT) {
            if (keymap_key_pressed_raw(SDLK_ESCAPE)) {
                mode = MODE_EDITING;
            } else if (keymap_key_pressed_raw(SDLK_RETURN) || keymap_key_pressed_raw(SDLK_KP_ENTER)) {
                try_open_from_prompt(window, &ed, &openPrompt, &mode);
            } else if (keymap_repeat_scancode(SDL_SCANCODE_BACKSPACE, now, &repOpenBackspace)) {
                if (openPrompt.len > 0) {
                    openPrompt.len = utf8_prev_start(openPrompt.path, openPrompt.len);
                    openPrompt.path[openPrompt.len] = '\0';
                    openPrompt.showError = false;
                }
            } else if (fi.textInputLen > 0) {
                int n = fi.textInputLen;
                if (openPrompt.len + n >= (int)sizeof(openPrompt.path) - 1) n = (int)sizeof(openPrompt.path) - 1 - openPrompt.len;
                if (n > 0) {
                    memcpy(openPrompt.path + openPrompt.len, fi.textInput, (size_t)n);
                    openPrompt.len += n;
                    openPrompt.path[openPrompt.len] = '\0';
                    openPrompt.showError = false;
                }
            }
        }

        if (fi.mouseLeftReleased) mouseDownInEditor = false;

        if (ed.cursorBlock != lastBlock || ed.cursorOffset != lastOffset) {
            blinkResetTime = now;
            lastBlock = ed.cursorBlock;
            lastOffset = ed.cursorOffset;
        }
        if (ed.dirty != lastDirty) {
            lastDirty = ed.dirty;
            update_window_title(window, &ed);
        }

        int winW, winH;
        SDL_GetWindowSize(window, &winW, &winH);
        Clay_SetPointerState(mousePos, mouseLeftDown);
        Clay_SetLayoutDimensions((Clay_Dimensions){ (float)winW, (float)winH });
        Clay_UpdateScrollContainers(false, (Clay_Vector2){ fi.wheelX, fi.wheelY * 40.0f }, deltaTime);

        float caretBlinkT = (float)(now - blinkResetTime);

        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,
                                           &turnIntoMenuState, &quickInsertMenuState, &menuClick, imageCache);
        SDL_RenderPresent(renderer);

        switch (action) {
            case MODAL_ACTION_SAVE_QUIT:
                editor_save(&ed);
                if (confirmForOpen) { openPrompt.len = 0; openPrompt.path[0] = '\0'; openPrompt.showError = false; mode = MODE_OPEN_PROMPT; }
                else windowClosing = true;
                break;
            case MODAL_ACTION_DISCARD_QUIT:
                if (confirmForOpen) { openPrompt.len = 0; openPrompt.path[0] = '\0'; openPrompt.showError = false; mode = MODE_OPEN_PROMPT; }
                else windowClosing = true;
                break;
            case MODAL_ACTION_CANCEL:        mode = MODE_EDITING; break;
            case MODAL_ACTION_CLOSE_HELP:    mode = MODE_EDITING; break;
            case MODAL_ACTION_OPEN_CONFIRM:  try_open_from_prompt(window, &ed, &openPrompt, &mode); break;
            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) {
            /* BLOCK_TABLE_HEADER_CELL doubles as the Table row's sentinel, same as in the
               quick-insert path -- Turn-Into normally preserves a block's content, but there's
               no meaningful way to preserve one block's text as a whole new grid, so this
               discards it and inserts a fresh table instead, exactly like quick-insert does. */
            if ((BlockType)menuClick.turnIntoClickedType == BLOCK_TABLE_HEADER_CELL) {
                editor_insert_table(&ed, 2, 2);
            } else {
                editor_turn_into_apply(&ed, (BlockType)menuClick.turnIntoClickedType);
            }
            turnIntoOpen = false;
        }
        if (menuClick.turnIntoClickedOutside) turnIntoOpen = false;

        if (menuClick.quickInsertClickedType >= 0) {
            if ((BlockType)menuClick.quickInsertClickedType == BLOCK_TABLE_HEADER_CELL) {
                editor_insert_table(&ed, 2, 2);
            } else {
                editor_quick_insert_apply(&ed, (BlockType)menuClick.quickInsertClickedType);
            }
            quickInsertDismissed = true;
            quickInsertOpen = false;
        }
    }

    layout_cache_free(&cache);
    image_cache_free(imageCache);
    editor_free(&ed);
    fonts_unload(fonts);
    lang_free();
    SDL_StopTextInput();
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    TTF_Quit();
    SDL_Quit();
    return 0;
}