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}