foxygit / Hush Log in
commit 3d462c9239b235f0a9b0472d860c9fb13de70e76
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Tue Aug 25 06:21:03 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Tue Aug 25 06:21:03 2026 +0200

    zoom
---
 README.md           |  3 ++
 assets/lang/en.lang |  5 +++
 assets/lang/sv.lang |  5 +++
 src/app_mode.h      |  1 +
 src/config.c        | 25 +++++++++++++++
 src/config.h        |  1 +
 src/editor.c        | 10 ++++++
 src/lang.c          |  6 ++++
 src/lang.h          |  3 ++
 src/main.c          | 15 +++++++++
 src/render.c        | 92 ++++++++++++++++++++++++++++++++++++++++++++++-------
 src/test_main.c     | 65 +++++++++++++++++++++++++++++++++++++
 src/theme.c         | 21 ++++++++++++
 src/theme.h         | 15 +++++++++
 14 files changed, 255 insertions(+), 12 deletions(-)

diff --git a/README.md b/README.md
index 8fcdb6f..233307b 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,9 @@ cmake --install build --prefix ~/.local
 | Ctrl+Z / Ctrl+Shift+Z | Undo / redo |
 | Ctrl+X | Quit (prompts to save if there are unsaved changes) |
 | F1 (or Ctrl+/) | Show this shortcut list |
+| Ctrl+M | View the raw markdown source (read-only; Ctrl+M or Esc to go back) |
+| Ctrl+= / Ctrl+- (or numpad +/-) | Zoom in / out |
+| Ctrl+0 (or numpad 0) | Reset zoom to 100% |
 | Enter | New line / new block |
 | Backspace / Delete | Delete a character (merges/demotes blocks at boundaries) |
 | Home / End | Start / end of the current line |
diff --git a/assets/lang/en.lang b/assets/lang/en.lang
index 85bcd34..bba09b8 100644
--- a/assets/lang/en.lang
+++ b/assets/lang/en.lang
@@ -32,4 +32,9 @@ help.this_help = F1 — This help
 help.enter = Enter — New line / new block
 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.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
diff --git a/assets/lang/sv.lang b/assets/lang/sv.lang
index 1d39c36..f559fae 100644
--- a/assets/lang/sv.lang
+++ b/assets/lang/sv.lang
@@ -29,4 +29,9 @@ help.this_help = F1 — Den här hjälpen
 help.enter = Enter — Ny rad / nytt block
 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.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
diff --git a/src/app_mode.h b/src/app_mode.h
index ac42ccd..f62ff34 100644
--- a/src/app_mode.h
+++ b/src/app_mode.h
@@ -8,6 +8,7 @@ typedef enum {
     MODE_EDITING,
     MODE_QUIT_CONFIRM,
     MODE_SHORTCUTS_HELP,
+    MODE_SOURCE_VIEW, /* read-only raw-markdown preview, toggled with Ctrl+M */
 } AppMode;

 typedef enum {
diff --git a/src/config.c b/src/config.c
index 8c7f03d..0ed7324 100644
--- a/src/config.c
+++ b/src/config.c
@@ -25,6 +25,7 @@ static void config_set_defaults(Config *cfg) {
     cfg->bgColor = DEFAULT_COL_BG;
     for (int i = 0; i < FONT_COUNT; i++) cfg->fontPaths[i] = NULL;
     cfg->language = dup_str(DEFAULT_LANGUAGE);
+    cfg->zoom = ZOOM_DEFAULT;
 }

 void config_free(Config *cfg) {
@@ -66,6 +67,18 @@ static bool parse_hex_color(const char *v, int len, Clay_Color *out) {
     return true;
 }

+static bool parse_float(const char *v, int len, float *out) {
+    char buf[64];
+    if (len <= 0 || len >= (int)sizeof buf) return false;
+    memcpy(buf, v, (size_t)len);
+    buf[len] = '\0';
+    char *end;
+    float f = strtof(buf, &end);
+    if (end != buf + len) return false; /* trailing garbage, e.g. "1.0x" */
+    *out = f;
+    return true;
+}
+
 bool config_parse_line(const char *line, int len, Config *cfg) {
     int trimmedLen;
     const char *t = trim(line, len, &trimmedLen);
@@ -89,6 +102,14 @@ bool config_parse_line(const char *line, int len, Config *cfg) {
         cfg->language[valueLen] = '\0';
         return true;
     }
+    if (keyLen == 4 && memcmp(key, "zoom", 4) == 0) {
+        float z;
+        if (!parse_float(value, valueLen, &z)) return false;
+        if (z < ZOOM_MIN) z = ZOOM_MIN;
+        if (z > ZOOM_MAX) z = ZOOM_MAX;
+        cfg->zoom = z;
+        return true;
+    }

     for (int i = 0; i < FONT_COUNT; i++) {
         size_t nameLen = strlen(FONT_KEY_NAMES[i]);
@@ -146,6 +167,10 @@ static void write_default_config(const char *path) {
         "# text_color = #%02X%02X%02X\n"
         "# bg_color = #%02X%02X%02X\n"
         "\n"
+        "# Starting zoom level (Ctrl+=/Ctrl+- still adjust it during the session; Ctrl+0 always\n"
+        "# resets to 100%% regardless of this). Range 0.5-3.0.\n"
+        "# zoom = 1.0\n"
+        "\n"
         "# Font overrides (paths to .ttf files). Relative paths are resolved relative to the\n"
         "# directory hush is launched from. Leave commented out to use the bundled DejaVu fonts.\n"
         "# font_sans_regular = /path/to/font.ttf\n"
diff --git a/src/config.h b/src/config.h
index 5bcd3dd..2b135b2 100644
--- a/src/config.h
+++ b/src/config.h
@@ -10,6 +10,7 @@ typedef struct {
     Clay_Color bgColor;
     char *fontPaths[FONT_COUNT]; /* malloc'd override path per slot, or NULL = bundled default */
     char *language; /* malloc'd language code (e.g. "sv"), passed to lang_load(); never NULL */
+    float zoom; /* starting zoom level (1.0 = 100%); Ctrl+/Ctrl- still adjust it during the session */
 } Config;

 /* Fills `out` with the built-in defaults, then overlays whatever is set in the user's config
diff --git a/src/editor.c b/src/editor.c
index 70ea916..ac5aeb4 100644
--- a/src/editor.c
+++ b/src/editor.c
@@ -90,6 +90,16 @@ static void maybe_autoformat_block(EditorState *ed) {

     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++;
diff --git a/src/lang.c b/src/lang.c
index ee48f18..3268914 100644
--- a/src/lang.c
+++ b/src/lang.c
@@ -26,7 +26,10 @@ static const char *KEY_NAMES[STR_COUNT] = {
     [STR_HELP_ENTER] = "help.enter",
     [STR_HELP_BACKSPACE_DELETE] = "help.backspace_delete",
     [STR_HELP_HOME_END] = "help.home_end",
+    [STR_HELP_SOURCE_VIEW] = "help.source_view",
+    [STR_HELP_ZOOM] = "help.zoom",
     [STR_HELP_CLOSE] = "help.close",
+    [STR_SOURCE_HINT] = "source_hint",
 };

 /* Built-in fallback, used for any key missing from the loaded language file (including when
@@ -52,7 +55,10 @@ static const char *DEFAULTS[STR_COUNT] = {
     [STR_HELP_ENTER] = "Enter \xE2\x80\x94 New line / new block",
     [STR_HELP_BACKSPACE_DELETE] = "Backspace / Delete \xE2\x80\x94 Delete a character",
     [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_CLOSE] = "Close",
+    [STR_SOURCE_HINT] = "Raw markdown source \xE2\x80\x94 read-only \xC2\xB7 Ctrl+M or Esc to go back",
 };

 static char *g_strings[STR_COUNT];
diff --git a/src/lang.h b/src/lang.h
index caae546..550c30a 100644
--- a/src/lang.h
+++ b/src/lang.h
@@ -27,7 +27,10 @@ typedef enum {
     STR_HELP_ENTER,
     STR_HELP_BACKSPACE_DELETE,
     STR_HELP_HOME_END,
+    STR_HELP_SOURCE_VIEW,
+    STR_HELP_ZOOM,
     STR_HELP_CLOSE,
+    STR_SOURCE_HINT,
     STR_COUNT,
 } StringId;

diff --git a/src/main.c b/src/main.c
index 96a14e8..524dcca 100644
--- a/src/main.c
+++ b/src/main.c
@@ -40,6 +40,7 @@ int main(int argc, char **argv) {
     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;

     Clay_Raylib_Initialize(1000, 800, "hush", FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_MSAA_4X_HINT);
     SetExitKey(KEY_NULL);
@@ -68,6 +69,7 @@ int main(int argc, char **argv) {
     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;

     bool windowClosing = false;
     AppMode mode = MODE_EDITING;
@@ -105,6 +107,17 @@ int main(int argc, char **argv) {
                    character on non-US keyboard layouts (e.g. it's the "-/_" key on a Swedish
                    layout, not "?"). F1 is a layout-independent fallback that's always reachable. */
                 mode = MODE_SHORTCUTS_HELP;
+            } else if (ctrl && IsKeyPressed(KEY_M)) {
+                mode = MODE_SOURCE_VIEW;
+            } else if (ctrl && (key_repeat(KEY_EQUAL, now, &repZoomIn) || key_repeat(KEY_KP_ADD, now, &repZoomInKp))) {
+                /* KEY_EQUAL is bound by physical scancode, same caveat as KEY_SLASH above (e.g. it's
+                   a dead accent key on a Swedish layout, not "="). KEY_KP_ADD (numpad +) is a
+                   layout-independent fallback for keyboards with a numpad. */
+                theme_zoom_in();
+            } else if (ctrl && (key_repeat(KEY_MINUS, now, &repZoomOut) || key_repeat(KEY_KP_SUBTRACT, now, &repZoomOutKp))) {
+                theme_zoom_out();
+            } else if (ctrl && (IsKeyPressed(KEY_ZERO) || IsKeyPressed(KEY_KP_0))) {
+                theme_zoom_reset();
             } else {
                 if (!ctrl) {
                     int cp;
@@ -143,6 +156,8 @@ int main(int argc, char **argv) {
             if (IsKeyPressed(KEY_ESCAPE)) mode = MODE_EDITING;
         } else if (mode == MODE_SHORTCUTS_HELP) {
             if (IsKeyPressed(KEY_ESCAPE) || IsKeyPressed(KEY_F1) || (ctrl && IsKeyPressed(KEY_SLASH))) mode = MODE_EDITING;
+        } else if (mode == MODE_SOURCE_VIEW) {
+            if (IsKeyPressed(KEY_ESCAPE) || (ctrl && IsKeyPressed(KEY_M))) mode = MODE_EDITING;
         }

         if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) mouseDownInEditor = false;
diff --git a/src/render.c b/src/render.c
index 75e0355..d6fc12f 100644
--- a/src/render.c
+++ b/src/render.c
@@ -58,6 +58,11 @@ typedef struct {
 static char g_scratch[SCRATCH_SIZE];
 static int g_scratchPos;

+/* Remembers the cursor position as of the last frame, so the "scroll the caret into view"
+   check (see render_frame) only fires on the frame the cursor actually moved -- otherwise it
+   would fight a free mouse-wheel scroll away from the cursor every single frame. */
+static int s_lastScrollCursorBlock = -1, s_lastScrollCursorOffset = -1;
+
 static char *scratch_alloc(int n) {
     if (g_scratchPos + n > SCRATCH_SIZE) n = SCRATCH_SIZE - g_scratchPos;
     if (n < 0) n = 0;
@@ -223,7 +228,7 @@ static void emit_wrapped_lines(RenderCtx *ctx, int blockIndex, const char *text,

 static void emit_code_block(RenderCtx *ctx, int blockIndex, const char *text, int len) {
     int fontId = FONT_MONO_REGULAR;
-    int fontSize = FS_CODE;
+    int fontSize = theme_scaled_font_size(FS_CODE);
     float lineHeight = (float)fontSize * LINE_HEIGHT_MULT;
     int lineInBlock = 0;
     int pos = 0;
@@ -279,6 +284,7 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
         case BLOCK_HR: topPad = HR_TOP_PAD; break;
         default: break;
     }
+    baseFontSize = theme_scaled_font_size(baseFontSize);

     Clay_ElementDeclaration decl = {0};
     decl.layout.layoutDirection = CLAY_TOP_TO_BOTTOM;
@@ -376,6 +382,41 @@ static void emit_block(RenderCtx *ctx, Document *doc, int blockIndex) {
     }
 }

+/* Read-only preview of the document as it would actually be saved (MODE_SOURCE_VIEW, toggled
+   with Ctrl+M) -- plain monospace text, one Clay text element per line (Clay wraps within an
+   element by width, not by embedded '\n', so each raw line needs its own element; word-wraps
+   long lines within the content width, same as everything else). */
+static void emit_source_view(EditorState *ed, float contentWidth) {
+    StrBuf tmp;
+    sb_init(&tmp);
+    document_serialize(&ed->doc, &tmp);
+
+    /* Clay_String only stores a pointer -- copy into the frame-lifetime scratch buffer before
+       `tmp` gets freed, not the other way around. */
+    char *src = scratch_alloc(tmp.len);
+    memcpy(src, tmp.data, (size_t)tmp.len);
+    int srcLen = tmp.len;
+    sb_free(&tmp);
+
+    int fontSize = theme_scaled_font_size(FS_CODE);
+    float lineHeight = (float)fontSize * LINE_HEIGHT_MULT;
+    int pos = 0;
+    while (pos < srcLen) {
+        int lineStart = pos;
+        while (pos < srcLen && src[pos] != '\n') pos++;
+        int lineEnd = pos;
+        if (pos < srcLen) pos++; /* consume the '\n' */
+
+        Clay_String txt = { .isStaticallyAllocated = false, .length = lineEnd - lineStart, .chars = src + lineStart };
+        CLAY_AUTO_ID({
+            .layout = { .sizing = { CLAY_SIZING_FIXED(contentWidth), CLAY_SIZING_FIT(lineHeight) } },
+        }) {
+            CLAY_TEXT(txt, CLAY_TEXT_CONFIG({ .fontId = FONT_MONO_REGULAR, .fontSize = (uint16_t)fontSize,
+                                                .textColor = g_theme.text, .wrapMode = CLAY_TEXT_WRAP_WORDS }));
+        }
+    }
+}
+
 static void modal_text_line(const char *text) {
     Clay_String s = { .isStaticallyAllocated = true, .length = (int)strlen(text), .chars = text };
     CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = FS_MODAL, .textColor = g_theme.text, .wrapMode = CLAY_TEXT_WRAP_NONE }));
@@ -408,10 +449,11 @@ static void modal_button(Clay_ElementId id, const char *label, ModalAction *acti

 /* Draws the quit-confirm or shortcuts-help modal (whichever `mode` calls for) as a floating,
    dimmed overlay on top of everything else, and returns the action the user's click resolved
-   to this frame (MODAL_ACTION_NONE if none). No-op when mode == MODE_EDITING. */
+   to this frame (MODAL_ACTION_NONE if none). No-op for any other mode (MODE_EDITING has
+   nothing to overlay; MODE_SOURCE_VIEW is a full alternate view, not a modal). */
 static ModalAction emit_modal(AppMode mode, float winW, float winH) {
     ModalAction action = MODAL_ACTION_NONE;
-    if (mode == MODE_EDITING) return action;
+    if (mode != MODE_QUIT_CONFIRM && mode != MODE_SHORTCUTS_HELP) return action;

     CLAY(CLAY_ID("ModalScrim"), {
         .layout = {
@@ -454,6 +496,8 @@ static ModalAction emit_modal(AppMode mode, float winW, float winH) {
                 modal_text_line(lang_get(STR_HELP_ENTER));
                 modal_text_line(lang_get(STR_HELP_BACKSPACE_DELETE));
                 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_button(CLAY_ID("BtnCloseHelp"), lang_get(STR_HELP_CLOSE), &action, MODAL_ACTION_CLOSE_HELP);
             }
         }
@@ -507,8 +551,12 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float
             CLAY(CLAY_ID("ContentColumn"), {
                 .layout = { .layoutDirection = CLAY_TOP_TO_BOTTOM, .sizing = { CLAY_SIZING_FIXED(contentWidth), CLAY_SIZING_FIT(0) } },
             }) {
-                for (int i = 0; i < ed->doc.count; i++) {
-                    emit_block(&ctx, &ed->doc, i);
+                if (mode == MODE_SOURCE_VIEW) {
+                    emit_source_view(ed, contentWidth);
+                } else {
+                    for (int i = 0; i < ed->doc.count; i++) {
+                        emit_block(&ctx, &ed->doc, i);
+                    }
                 }
             }
         }
@@ -522,14 +570,19 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float
             },
             .backgroundColor = COL_STATUSBAR_BG,
         }) {
-            DocStats stats;
-            document_compute_stats(&ed->doc, &stats);
-            const char *name = editor_display_name(ed);
-
             char tmp[512];
-            int n = snprintf(tmp, sizeof tmp, "%s%s  \xC2\xB7  %.1f KB  \xC2\xB7  %d %s",
-                              ed->dirty ? "*" : "", name, stats.byteSize / 1024.0, stats.lineCount,
-                              lang_get(stats.lineCount == 1 ? STR_STATUSBAR_LINE : STR_STATUSBAR_LINES));
+            int n;
+            if (mode == MODE_SOURCE_VIEW) {
+                const char *hint = lang_get(STR_SOURCE_HINT);
+                n = snprintf(tmp, sizeof tmp, "%s", hint);
+            } else {
+                DocStats stats;
+                document_compute_stats(&ed->doc, &stats);
+                const char *name = editor_display_name(ed);
+                n = snprintf(tmp, sizeof tmp, "%s%s  \xC2\xB7  %.1f KB  \xC2\xB7  %d %s",
+                             ed->dirty ? "*" : "", name, stats.byteSize / 1024.0, stats.lineCount,
+                             lang_get(stats.lineCount == 1 ? STR_STATUSBAR_LINE : STR_STATUSBAR_LINES));
+            }
             if (n > (int)sizeof tmp) n = (int)sizeof tmp;
             char *scratchBuf = scratch_alloc(n);
             memcpy(scratchBuf, tmp, (size_t)n);
@@ -577,6 +630,21 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float
     if (mode == MODE_EDITING) {
         float cx, cy, ch;
         if (hittest_caret(cache, &ed->doc, fonts, ed->cursorBlock, ed->cursorOffset, &cx, &cy, &ch)) {
+            if (ed->cursorBlock != s_lastScrollCursorBlock || ed->cursorOffset != s_lastScrollCursorOffset) {
+                s_lastScrollCursorBlock = ed->cursorBlock;
+                s_lastScrollCursorOffset = ed->cursorOffset;
+                Clay_ElementData viewport = Clay_GetElementData(CLAY_ID("ScrollArea"));
+                Clay_ScrollContainerData scroll = Clay_GetScrollContainerData(CLAY_ID("ScrollArea"));
+                if (viewport.found && scroll.found) {
+                    float viewTop = viewport.boundingBox.y;
+                    float viewBottom = viewTop + viewport.boundingBox.height;
+                    if (cy < viewTop) {
+                        scroll.scrollPosition->y += viewTop - cy;
+                    } else if (cy + ch > viewBottom) {
+                        scroll.scrollPosition->y -= (cy + ch) - viewBottom;
+                    }
+                }
+            }
             if (fmodf(caretBlinkT, 1.0f) < 0.5f) {
                 Color cursorColor = {
                     (unsigned char)roundf(g_theme.cursor.r), (unsigned char)roundf(g_theme.cursor.g),
diff --git a/src/test_main.c b/src/test_main.c
index f236fda..8f62836 100644
--- a/src/test_main.c
+++ b/src/test_main.c
@@ -7,9 +7,11 @@
 #include "undo.h"
 #include "config.h"
 #include "lang.h"
+#include "theme.h"
 #include <stdio.h>
 #include <string.h>
 #include <stdlib.h>
+#include <math.h>

 static int g_failures = 0;

@@ -410,6 +412,19 @@ static void test_config_parse_line(void) {
     CHECK(config_parse_line(l6, (int)strlen(l6), &cfg), "a trailing \\r is trimmed and the line still parses");
     CHECK(cfg.textColor.r == 0xa1, "value from the \\r-trimmed line is correct");

+    const char *l7 = "zoom = 1.5";
+    CHECK(config_parse_line(l7, (int)strlen(l7), &cfg), "a valid zoom value returns true");
+    CHECK(cfg.zoom > 1.499f && cfg.zoom < 1.501f, "zoom parses as a float");
+
+    const char *l8 = "zoom = 99";
+    CHECK(config_parse_line(l8, (int)strlen(l8), &cfg), "an out-of-range zoom value still returns true");
+    CHECK(cfg.zoom <= ZOOM_MAX, "but is clamped to ZOOM_MAX rather than taken literally");
+
+    const char *l9 = "zoom = not-a-number";
+    float zoomBefore = cfg.zoom;
+    CHECK(!config_parse_line(l9, (int)strlen(l9), &cfg), "a non-numeric zoom value returns false");
+    CHECK(cfg.zoom == zoomBefore, "an invalid zoom line leaves the previous value untouched");
+
     config_free(&cfg);
 }

@@ -434,6 +449,32 @@ static void test_lang_parse_line(void) {
     CHECK(strcmp(lang_get(STR_HELP_CLOSE), "Close") == 0, "lang_free() clears overrides back to the English defaults");
 }

+static void test_theme_zoom(void) {
+    theme_init_defaults();
+    CHECK(g_theme.zoom > 0.999f && g_theme.zoom < 1.001f, "zoom starts at 100%");
+    CHECK(theme_scaled_font_size(16) == 16, "at 100% zoom, scaling is a no-op");
+
+    theme_zoom_in();
+    CHECK(g_theme.zoom > 1.099f && g_theme.zoom < 1.101f, "zoom in steps up by ZOOM_STEP");
+    CHECK(theme_scaled_font_size(16) == (int)roundf(16 * 1.1f), "scaled size reflects the new zoom level");
+
+    theme_zoom_out();
+    theme_zoom_out();
+    CHECK(g_theme.zoom > 0.899f && g_theme.zoom < 0.901f, "zoom out steps back down by ZOOM_STEP each time");
+
+    theme_zoom_reset();
+    CHECK(g_theme.zoom > 0.999f && g_theme.zoom < 1.001f, "zoom_reset always returns to 100%, regardless of prior zoom level");
+
+    for (int i = 0; i < 100; i++) theme_zoom_in();
+    CHECK(g_theme.zoom <= ZOOM_MAX + 0.001f, "zoom in is clamped at ZOOM_MAX, doesn't grow unbounded");
+
+    for (int i = 0; i < 100; i++) theme_zoom_out();
+    CHECK(g_theme.zoom >= ZOOM_MIN - 0.001f, "zoom out is clamped at ZOOM_MIN, doesn't shrink unbounded");
+    CHECK(theme_scaled_font_size(1) >= 6, "scaled font size never drops below the 6px floor even at minimum zoom");
+
+    theme_zoom_reset();
+}
+
 static void test_heading_levels_4_5_6(void) {
     EditorState ed;
     editor_init(&ed);
@@ -496,6 +537,28 @@ static void test_horizontal_rule(void) {
     editor_free(&ed2);
 }

+static void test_horizontal_rule_via_space(void) {
+    EditorState ed;
+    editor_init(&ed);
+    type_str(&ed, "--- ");
+    CHECK(ed.doc.blocks[0].type == BLOCK_HR, "typing '--- ' (space, not Enter) also converts the block to a horizontal rule");
+    CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH, "a fresh paragraph is inserted after it, same as the Enter-triggered path");
+    CHECK(ed.cursorBlock == 1, "cursor moves into that fresh paragraph");
+    editor_free(&ed);
+
+    EditorState ed2;
+    editor_init(&ed2);
+    type_str(&ed2, "*** ");
+    CHECK(ed2.doc.blocks[0].type == BLOCK_HR, "'*** ' also triggers a horizontal rule");
+    editor_free(&ed2);
+
+    EditorState ed3;
+    editor_init(&ed3);
+    type_str(&ed3, "___ ");
+    CHECK(ed3.doc.blocks[0].type == BLOCK_HR, "'___ ' also triggers a horizontal rule");
+    editor_free(&ed3);
+}
+
 static void test_task_list(void) {
     EditorState ed;
     editor_init(&ed);
@@ -621,9 +684,11 @@ int main(void) {
     test_selection_replace_on_backspace_across_blocks();
     test_config_parse_line();
     test_lang_parse_line();
+    test_theme_zoom();
     test_heading_levels_4_5_6();
     test_heading_levels_round_trip();
     test_horizontal_rule();
+    test_horizontal_rule_via_space();
     test_task_list();
     test_task_list_load_variants();
     test_code_fence_with_language();
diff --git a/src/theme.c b/src/theme.c
index 307f4c0..93d748e 100644
--- a/src/theme.c
+++ b/src/theme.c
@@ -1,4 +1,5 @@
 #include "theme.h"
+#include <math.h>

 Theme g_theme;

@@ -6,4 +7,24 @@ void theme_init_defaults(void) {
     g_theme.bg = DEFAULT_COL_BG;
     g_theme.text = DEFAULT_COL_TEXT;
     g_theme.cursor = DEFAULT_COL_CURSOR;
+    g_theme.zoom = ZOOM_DEFAULT;
+}
+
+void theme_zoom_in(void) {
+    g_theme.zoom += ZOOM_STEP;
+    if (g_theme.zoom > ZOOM_MAX) g_theme.zoom = ZOOM_MAX;
+}
+
+void theme_zoom_out(void) {
+    g_theme.zoom -= ZOOM_STEP;
+    if (g_theme.zoom < ZOOM_MIN) g_theme.zoom = ZOOM_MIN;
+}
+
+void theme_zoom_reset(void) {
+    g_theme.zoom = ZOOM_DEFAULT;
+}
+
+int theme_scaled_font_size(int baseSize) {
+    int scaled = (int)roundf((float)baseSize * g_theme.zoom);
+    return scaled < 6 ? 6 : scaled;
 }
diff --git a/src/theme.h b/src/theme.h
index 7120f6a..dba8f8e 100644
--- a/src/theme.h
+++ b/src/theme.h
@@ -14,11 +14,26 @@ typedef struct {
     Clay_Color bg;
     Clay_Color text;
     Clay_Color cursor;
+    float zoom; /* multiplies document content font sizes (headings/body/code); 1.0 = 100% */
 } Theme;

 extern Theme g_theme;
 void theme_init_defaults(void);

+#define ZOOM_DEFAULT 1.0f
+#define ZOOM_MIN 0.5f
+#define ZOOM_MAX 3.0f
+#define ZOOM_STEP 0.1f
+
+void theme_zoom_in(void);
+void theme_zoom_out(void);
+void theme_zoom_reset(void);
+
+/* Scales `baseSize` by the current zoom level, rounded, floored at 6px so extreme zoom-out
+   never produces an unreadable-or-degenerate font size. Used only for document content
+   (headings/body/code) -- status bar, modals, and the code-block language label stay fixed. */
+int theme_scaled_font_size(int baseSize);
+
 #define COL_TEXT_QUOTE  (Clay_Color){90, 97, 105, 255}
 #define COL_MARKER      (Clay_Color){178, 178, 172, 255}
 #define COL_LINK        (Clay_Color){37, 99, 235, 255}