foxygit / Hush Log in
commit 84b14f73aaf44bd767a3e900834d8b453c29fba9
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Mon Aug 24 19:03:09 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Mon Aug 24 19:03:09 2026 +0200

    Add a translatable language file system

    Status bar text and the quit-confirm/shortcuts-help dialogs now go
    through a small string table (src/lang.c) loaded from plain-text
    assets/lang/<code>.lang files, instead of being hardcoded. Ships with
    sv.lang (the previous hardcoded Swedish, still the default) and
    en.lang (new; also the compiled-in fallback for any missing key).
    Switch with `language = <code>` in the config file.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 CMakeLists.txt      |   2 +
 README.md           |  13 ++++++
 assets/lang/en.lang |  35 +++++++++++++++
 assets/lang/sv.lang |  32 ++++++++++++++
 src/config.c        |  24 +++++++++++
 src/config.h        |   1 +
 src/editor.c        |   3 +-
 src/lang.c          | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/lang.h          |  51 ++++++++++++++++++++++
 src/main.c          |   3 ++
 src/render.c        |  49 +++++++++++----------
 src/test_main.c     |  23 ++++++++++
 12 files changed, 334 insertions(+), 23 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3cd53c0..d5d20a5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -35,6 +35,7 @@ add_executable(hush
     src/theme.c
     src/undo.c
     src/config.c
+    src/lang.c
     src/editor.c
     src/render.c
     src/markdown_io.c
@@ -64,6 +65,7 @@ add_executable(hush_test
     src/theme.c
     src/undo.c
     src/config.c
+    src/lang.c
     src/editor.c
     src/render.c
     src/markdown_io.c
diff --git a/README.md b/README.md
index 6d0c817..d2f7002 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ Markdown syntax you type (`# `, `**bold**`, `` `code` ``, `- `, `1. `, `> `, ``
 - Undo/redo
 - A status bar showing the document name, size, and line count
 - A config file for font, text color, and background color
+- Translatable UI (Swedish and English included; see [Language](#language) below)
 - Full Swedish/Latin-1 character support (åäö, etc.)

 ## Building
@@ -73,6 +74,18 @@ font_mono_bold_italic = /path/to/mono-bold-italic.ttf

 Font paths are optional per style; any left unset fall back to the bundled DejaVu fonts. `text_color` and `bg_color` accept `#RRGGBB` (the `#` is optional).

+## Language
+
+The status bar and the quit/shortcuts dialogs are translated via plain-text files in `assets/lang/` (document content itself is never touched — Hush doesn't translate what *you* write). Two are included: `sv.lang` (Swedish, the default) and `en.lang` (English, also the built-in fallback for any string a translation leaves out).
+
+Set the active language in the config file:
+
+```
+language = en
+```
+
+To add a new language, copy `assets/lang/en.lang` to `assets/lang/<code>.lang`, translate the values (not the keys), and set `language = <code>`. Malformed lines are ignored, same as the main config file. Note that the bundled fonts only cover ASCII and Latin-1 (Western European accented characters); a translation needing other scripts would need a matching font as well (see Configuration above).
+
 ## Testing

 ```sh
diff --git a/assets/lang/en.lang b/assets/lang/en.lang
new file mode 100644
index 0000000..85bcd34
--- /dev/null
+++ b/assets/lang/en.lang
@@ -0,0 +1,35 @@
+# Hush language file -- English (built-in default)
+#
+# This file is provided as a complete, up-to-date reference translation: every string hush
+# knows how to show is listed here. To add a new language, copy this file to <code>.lang
+# (e.g. de.lang for German) in this same directory, translate the values, and set
+# `language = <code>` in your hush config file (see the README). Keys are fixed and must not
+# be translated or renamed. Lines starting with # are comments; blank lines are ignored.
+# Save this file as UTF-8.
+
+untitled = Untitled
+
+# Status bar: "<name>  ·  1.2 KB  ·  3 <line/lines>"
+statusbar.line = line
+statusbar.lines = lines
+
+# Ctrl+X / window-close quit-confirmation dialog (only shown if there are unsaved changes)
+quit.title = Save changes before quitting?
+quit.save = Save and quit
+quit.discard = Quit without saving
+quit.cancel = Cancel
+
+# F1 / Ctrl+/ shortcuts-help dialog
+help.title = Keyboard shortcuts
+help.arrows = Arrow keys — move the cursor
+help.shift_arrows = Shift + arrow keys — select text
+help.mouse_drag = Click + drag — select text
+help.save = Ctrl+S — Save
+help.copy_paste = Ctrl+C / Ctrl+V — Copy / paste
+help.undo_redo = Ctrl+Z / Ctrl+Shift+Z — Undo / redo
+help.quit = Ctrl+X — Quit
+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.close = Close
diff --git a/assets/lang/sv.lang b/assets/lang/sv.lang
new file mode 100644
index 0000000..1d39c36
--- /dev/null
+++ b/assets/lang/sv.lang
@@ -0,0 +1,32 @@
+# Hush språkfil -- svenska
+#
+# Se assets/lang/en.lang för en fullständig referens över alla tillgängliga nycklar (nycklarna
+# själva ska inte översättas). Rader som börjar med # är kommentarer; tomma rader ignoreras.
+# Spara filen som UTF-8.
+
+untitled = Namnlös
+
+# Statusfältet: "<namn>  ·  1,2 KB  ·  3 <rad/rader>"
+statusbar.line = rad
+statusbar.lines = rader
+
+# Bekräftelsedialog vid Ctrl+X / stängning av fönstret (visas bara om det finns osparade ändringar)
+quit.title = Spara ändringar innan du avslutar?
+quit.save = Spara och avsluta
+quit.discard = Avsluta utan att spara
+quit.cancel = Avbryt
+
+# Genvägshjälp vid F1 / Ctrl+/
+help.title = Kortkommandon
+help.arrows = Piltangenter — flytta markören
+help.shift_arrows = Shift + piltangenter — markera text
+help.mouse_drag = Musklick + drag — markera text
+help.save = Ctrl+S — Spara
+help.copy_paste = Ctrl+C / Ctrl+V — Kopiera / klistra in
+help.undo_redo = Ctrl+Z / Ctrl+Shift+Z — Ångra / gör om
+help.quit = Ctrl+X — Avsluta
+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.close = Stäng
diff --git a/src/config.c b/src/config.c
index 0ec6bc2..8c7f03d 100644
--- a/src/config.c
+++ b/src/config.c
@@ -11,10 +11,20 @@ static const char *FONT_KEY_NAMES[FONT_COUNT] = {
     "font_mono_regular", "font_mono_bold", "font_mono_italic", "font_mono_bold_italic",
 };

+#define DEFAULT_LANGUAGE "sv"
+
+static char *dup_str(const char *s) {
+    size_t n = strlen(s);
+    char *copy = malloc(n + 1);
+    memcpy(copy, s, n + 1);
+    return copy;
+}
+
 static void config_set_defaults(Config *cfg) {
     cfg->textColor = DEFAULT_COL_TEXT;
     cfg->bgColor = DEFAULT_COL_BG;
     for (int i = 0; i < FONT_COUNT; i++) cfg->fontPaths[i] = NULL;
+    cfg->language = dup_str(DEFAULT_LANGUAGE);
 }

 void config_free(Config *cfg) {
@@ -22,6 +32,8 @@ void config_free(Config *cfg) {
         free(cfg->fontPaths[i]);
         cfg->fontPaths[i] = NULL;
     }
+    free(cfg->language);
+    cfg->language = NULL;
 }

 static const char *trim(const char *s, int len, int *outLen) {
@@ -70,6 +82,13 @@ bool config_parse_line(const char *line, int len, Config *cfg) {

     if (keyLen == 10 && memcmp(key, "text_color", 10) == 0) return parse_hex_color(value, valueLen, &cfg->textColor);
     if (keyLen == 8 && memcmp(key, "bg_color", 8) == 0) return parse_hex_color(value, valueLen, &cfg->bgColor);
+    if (keyLen == 8 && memcmp(key, "language", 8) == 0) {
+        free(cfg->language);
+        cfg->language = malloc((size_t)valueLen + 1);
+        memcpy(cfg->language, value, (size_t)valueLen);
+        cfg->language[valueLen] = '\0';
+        return true;
+    }

     for (int i = 0; i < FONT_COUNT; i++) {
         size_t nameLen = strlen(FONT_KEY_NAMES[i]);
@@ -119,6 +138,11 @@ static void write_default_config(const char *path) {
         "# Uncomment and edit any line below to override the default. Unknown or malformed\n"
         "# lines are ignored, so it's safe to experiment.\n"
         "\n"
+        "# Language for the status bar and the quit/shortcuts dialogs. See assets/lang/ next to\n"
+        "# the hush binary for available codes (or add your own -- copy en.lang, translate it,\n"
+        "# name it <code>.lang, and set that code here).\n"
+        "# language = " DEFAULT_LANGUAGE "\n"
+        "\n"
         "# text_color = #%02X%02X%02X\n"
         "# bg_color = #%02X%02X%02X\n"
         "\n"
diff --git a/src/config.h b/src/config.h
index 5ca17ac..5bcd3dd 100644
--- a/src/config.h
+++ b/src/config.h
@@ -9,6 +9,7 @@ typedef struct {
     Clay_Color textColor;
     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 */
 } 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 0126c0b..50a6ce5 100644
--- a/src/editor.c
+++ b/src/editor.c
@@ -2,6 +2,7 @@
 #include "markdown_io.h"
 #include "hittest.h"
 #include "utf8.h"
+#include "lang.h"
 #include <stdlib.h>
 #include <string.h>
 #include <ctype.h>
@@ -62,7 +63,7 @@ bool editor_save(EditorState *ed) {
 }

 const char *editor_display_name(const EditorState *ed) {
-    if (!ed->filePath) return "Untitled";
+    if (!ed->filePath) return lang_get(STR_UNTITLED);
     const char *slash = strrchr(ed->filePath, '/');
     return slash ? slash + 1 : ed->filePath;
 }
diff --git a/src/lang.c b/src/lang.c
new file mode 100644
index 0000000..74cb0be
--- /dev/null
+++ b/src/lang.c
@@ -0,0 +1,121 @@
+#include "lang.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+
+/* File-format key for each StringId (see the .lang files under assets/lang). */
+static const char *KEY_NAMES[STR_COUNT] = {
+    [STR_UNTITLED] = "untitled",
+    [STR_STATUSBAR_LINE] = "statusbar.line",
+    [STR_STATUSBAR_LINES] = "statusbar.lines",
+    [STR_QUIT_TITLE] = "quit.title",
+    [STR_QUIT_SAVE] = "quit.save",
+    [STR_QUIT_DISCARD] = "quit.discard",
+    [STR_QUIT_CANCEL] = "quit.cancel",
+    [STR_HELP_TITLE] = "help.title",
+    [STR_HELP_ARROWS] = "help.arrows",
+    [STR_HELP_SHIFT_ARROWS] = "help.shift_arrows",
+    [STR_HELP_MOUSE_DRAG] = "help.mouse_drag",
+    [STR_HELP_SAVE] = "help.save",
+    [STR_HELP_COPY_PASTE] = "help.copy_paste",
+    [STR_HELP_UNDO_REDO] = "help.undo_redo",
+    [STR_HELP_QUIT] = "help.quit",
+    [STR_HELP_THIS_HELP] = "help.this_help",
+    [STR_HELP_ENTER] = "help.enter",
+    [STR_HELP_BACKSPACE_DELETE] = "help.backspace_delete",
+    [STR_HELP_HOME_END] = "help.home_end",
+    [STR_HELP_CLOSE] = "help.close",
+};
+
+/* Built-in fallback, used for any key missing from the loaded language file (including when
+   no file could be loaded at all). Kept in English as the baseline other translations are
+   written against. */
+static const char *DEFAULTS[STR_COUNT] = {
+    [STR_UNTITLED] = "Untitled",
+    [STR_STATUSBAR_LINE] = "line",
+    [STR_STATUSBAR_LINES] = "lines",
+    [STR_QUIT_TITLE] = "Save changes before quitting?",
+    [STR_QUIT_SAVE] = "Save and quit",
+    [STR_QUIT_DISCARD] = "Quit without saving",
+    [STR_QUIT_CANCEL] = "Cancel",
+    [STR_HELP_TITLE] = "Keyboard shortcuts",
+    [STR_HELP_ARROWS] = "Arrow keys \xE2\x80\x94 move the cursor",
+    [STR_HELP_SHIFT_ARROWS] = "Shift + arrow keys \xE2\x80\x94 select text",
+    [STR_HELP_MOUSE_DRAG] = "Click + drag \xE2\x80\x94 select text",
+    [STR_HELP_SAVE] = "Ctrl+S \xE2\x80\x94 Save",
+    [STR_HELP_COPY_PASTE] = "Ctrl+C / Ctrl+V \xE2\x80\x94 Copy / paste",
+    [STR_HELP_UNDO_REDO] = "Ctrl+Z / Ctrl+Shift+Z \xE2\x80\x94 Undo / redo",
+    [STR_HELP_QUIT] = "Ctrl+X \xE2\x80\x94 Quit",
+    [STR_HELP_THIS_HELP] = "F1 \xE2\x80\x94 This help",
+    [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_CLOSE] = "Close",
+};
+
+static char *g_strings[STR_COUNT];
+
+static const char *trim(const char *s, int len, int *outLen) {
+    while (len > 0 && isspace((unsigned char)s[0])) { s++; len--; }
+    while (len > 0 && isspace((unsigned char)s[len - 1])) len--;
+    *outLen = len;
+    return s;
+}
+
+bool lang_parse_line(const char *line, int len) {
+    int trimmedLen;
+    const char *t = trim(line, len, &trimmedLen);
+    if (trimmedLen == 0 || t[0] == '#') return false;
+
+    const char *eq = memchr(t, '=', (size_t)trimmedLen);
+    if (!eq) return false;
+
+    int keyLen;
+    const char *key = trim(t, (int)(eq - t), &keyLen);
+    int valueLen;
+    const char *value = trim(eq + 1, (int)(t + trimmedLen - (eq + 1)), &valueLen);
+    if (keyLen == 0 || valueLen == 0) return false;
+
+    for (int i = 0; i < STR_COUNT; i++) {
+        size_t nameLen = strlen(KEY_NAMES[i]);
+        if ((size_t)keyLen == nameLen && memcmp(key, KEY_NAMES[i], nameLen) == 0) {
+            free(g_strings[i]);
+            g_strings[i] = malloc((size_t)valueLen + 1);
+            memcpy(g_strings[i], value, (size_t)valueLen);
+            g_strings[i][valueLen] = '\0';
+            return true;
+        }
+    }
+
+    return false;
+}
+
+void lang_load(const char *code) {
+    if (!code || !code[0]) return;
+
+    char path[1024];
+    snprintf(path, sizeof path, "%s/lang/%s.lang", ASSETS_DIR, code);
+
+    FILE *f = fopen(path, "rb");
+    if (!f) return;
+
+    char line[1024];
+    while (fgets(line, sizeof line, f)) {
+        int len = (int)strlen(line);
+        while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) len--;
+        lang_parse_line(line, len);
+    }
+    fclose(f);
+}
+
+void lang_free(void) {
+    for (int i = 0; i < STR_COUNT; i++) {
+        free(g_strings[i]);
+        g_strings[i] = NULL;
+    }
+}
+
+const char *lang_get(StringId id) {
+    return g_strings[id] ? g_strings[id] : DEFAULTS[id];
+}
diff --git a/src/lang.h b/src/lang.h
new file mode 100644
index 0000000..caae546
--- /dev/null
+++ b/src/lang.h
@@ -0,0 +1,51 @@
+#ifndef HUSH_LANG_H
+#define HUSH_LANG_H
+
+#include <stdbool.h>
+
+/* All user-facing UI text (status bar, quit-confirm and shortcuts-help modals) goes through
+   this table, so a translation only requires a new assets/lang/<code>.lang file -- no source
+   changes. Every StringId has a compiled-in English fallback (see lang.c), used for any key
+   missing from the loaded language file, so a partial translation never shows blank text. */
+typedef enum {
+    STR_UNTITLED,
+    STR_STATUSBAR_LINE,
+    STR_STATUSBAR_LINES,
+    STR_QUIT_TITLE,
+    STR_QUIT_SAVE,
+    STR_QUIT_DISCARD,
+    STR_QUIT_CANCEL,
+    STR_HELP_TITLE,
+    STR_HELP_ARROWS,
+    STR_HELP_SHIFT_ARROWS,
+    STR_HELP_MOUSE_DRAG,
+    STR_HELP_SAVE,
+    STR_HELP_COPY_PASTE,
+    STR_HELP_UNDO_REDO,
+    STR_HELP_QUIT,
+    STR_HELP_THIS_HELP,
+    STR_HELP_ENTER,
+    STR_HELP_BACKSPACE_DELETE,
+    STR_HELP_HOME_END,
+    STR_HELP_CLOSE,
+    STR_COUNT,
+} StringId;
+
+/* Loads assets/lang/<code>.lang (e.g. code="sv" -> ASSETS_DIR "/lang/sv.lang"). Any key not
+   present in the file (or if the file can't be opened at all, e.g. an unknown language code)
+   silently keeps the built-in English default for that string -- never fatal. Call once at
+   startup, before the main loop; the loaded strings live for the rest of the process. */
+void lang_load(const char *code);
+void lang_free(void);
+
+/* Returns the current text for `id` -- the loaded translation if set, else the English
+   default. The returned pointer is valid for the remaining lifetime of the process, so it's
+   safe to hand directly to Clay without a per-frame copy. */
+const char *lang_get(StringId id);
+
+/* Exposed for headless unit testing of the line parser without touching a real file. Returns
+   true if the line set a recognized key; false for comments/blank/unrecognized/malformed
+   lines (never fatal, same contract as config_parse_line). */
+bool lang_parse_line(const char *line, int len);
+
+#endif
diff --git a/src/main.c b/src/main.c
index f6c7342..31758eb 100644
--- a/src/main.c
+++ b/src/main.c
@@ -7,6 +7,7 @@
 #include "theme.h"
 #include "app_mode.h"
 #include "config.h"
+#include "lang.h"
 #include <stdlib.h>
 #include <string.h>
 #include <stdio.h>
@@ -32,6 +33,7 @@ static void update_window_title(EditorState *ed) {
 int main(int argc, char **argv) {
     Config cfg;
     config_load(&cfg); /* pure file I/O, no raylib dependency -- safe to run before window init */
+    lang_load(cfg.language);
     theme_init_defaults();
     g_theme.bg = cfg.bgColor;
     g_theme.text = cfg.textColor;
@@ -178,6 +180,7 @@ int main(int argc, char **argv) {
     layout_cache_free(&cache);
     editor_free(&ed);
     fonts_unload(fonts);
+    lang_free();
     Clay_Raylib_Close();
     return 0;
 }
diff --git a/src/render.c b/src/render.c
index 675a516..e064da0 100644
--- a/src/render.c
+++ b/src/render.c
@@ -6,6 +6,7 @@
 #include "wrap.h"
 #include "hittest.h"
 #include "markdown_io.h"
+#include "lang.h"
 #include <stdlib.h>
 #include <string.h>
 #include <stdio.h>
@@ -314,6 +315,12 @@ static void modal_text_line(const char *text) {
     CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_REGULAR, .fontSize = FS_MODAL, .textColor = g_theme.text, .wrapMode = CLAY_TEXT_WRAP_NONE }));
 }

+/* A modal's title line (bold, otherwise styled like modal_text_line). */
+static void modal_text_line_bold(const char *text) {
+    Clay_String s = { .isStaticallyAllocated = true, .length = (int)strlen(text), .chars = text };
+    CLAY_TEXT(s, CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_BOLD, .fontSize = FS_MODAL, .textColor = g_theme.text, .wrapMode = CLAY_TEXT_WRAP_NONE }));
+}
+
 /* A clickable modal button: highlights on hover, and sets *action to `thisAction` on click. */
 static void modal_button(Clay_ElementId id, const char *label, ModalAction *action, ModalAction thisAction) {
     bool hovered = Clay_PointerOver(id);
@@ -364,26 +371,24 @@ static ModalAction emit_modal(AppMode mode, float winW, float winH) {
             .border = { .color = COL_MODAL_BORDER, .width = CLAY_BORDER_OUTSIDE(1) },
         }) {
             if (mode == MODE_QUIT_CONFIRM) {
-                CLAY_TEXT(CLAY_STRING("Spara \xC3\xA4ndringar innan du avslutar?"),
-                          CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_BOLD, .fontSize = FS_MODAL, .textColor = g_theme.text }));
-                modal_button(CLAY_ID("BtnSaveQuit"), "Spara och avsluta", &action, MODAL_ACTION_SAVE_QUIT);
-                modal_button(CLAY_ID("BtnDiscardQuit"), "Avsluta utan att spara", &action, MODAL_ACTION_DISCARD_QUIT);
-                modal_button(CLAY_ID("BtnCancel"), "Avbryt", &action, MODAL_ACTION_CANCEL);
+                modal_text_line_bold(lang_get(STR_QUIT_TITLE));
+                modal_button(CLAY_ID("BtnSaveQuit"), lang_get(STR_QUIT_SAVE), &action, MODAL_ACTION_SAVE_QUIT);
+                modal_button(CLAY_ID("BtnDiscardQuit"), lang_get(STR_QUIT_DISCARD), &action, MODAL_ACTION_DISCARD_QUIT);
+                modal_button(CLAY_ID("BtnCancel"), lang_get(STR_QUIT_CANCEL), &action, MODAL_ACTION_CANCEL);
             } else {
-                CLAY_TEXT(CLAY_STRING("Kortkommandon"),
-                          CLAY_TEXT_CONFIG({ .fontId = FONT_SANS_BOLD, .fontSize = FS_MODAL, .textColor = g_theme.text }));
-                modal_text_line("Piltangenter \xE2\x80\x94 flytta mark\xC3\xB6ren");
-                modal_text_line("Shift + piltangenter \xE2\x80\x94 markera text");
-                modal_text_line("Musklick + drag \xE2\x80\x94 markera text");
-                modal_text_line("Ctrl+S \xE2\x80\x94 Spara");
-                modal_text_line("Ctrl+C / Ctrl+V \xE2\x80\x94 Kopiera / klistra in");
-                modal_text_line("Ctrl+Z / Ctrl+Shift+Z \xE2\x80\x94 \xC3\x85ngra / g\xC3\xB6r om");
-                modal_text_line("Ctrl+X \xE2\x80\x94 Avsluta");
-                modal_text_line("F1 \xE2\x80\x94 Den h\xC3\xA4r hj\xC3\xA4lpen");
-                modal_text_line("Enter \xE2\x80\x94 Ny rad / nytt block");
-                modal_text_line("Backspace / Delete \xE2\x80\x94 Ta bort tecken");
-                modal_text_line("Home / End \xE2\x80\x94 Radens b\xC3\xB6rjan/slut");
-                modal_button(CLAY_ID("BtnCloseHelp"), "St\xC3\xA4ng", &action, MODAL_ACTION_CLOSE_HELP);
+                modal_text_line_bold(lang_get(STR_HELP_TITLE));
+                modal_text_line(lang_get(STR_HELP_ARROWS));
+                modal_text_line(lang_get(STR_HELP_SHIFT_ARROWS));
+                modal_text_line(lang_get(STR_HELP_MOUSE_DRAG));
+                modal_text_line(lang_get(STR_HELP_SAVE));
+                modal_text_line(lang_get(STR_HELP_COPY_PASTE));
+                modal_text_line(lang_get(STR_HELP_UNDO_REDO));
+                modal_text_line(lang_get(STR_HELP_QUIT));
+                modal_text_line(lang_get(STR_HELP_THIS_HELP));
+                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_button(CLAY_ID("BtnCloseHelp"), lang_get(STR_HELP_CLOSE), &action, MODAL_ACTION_CLOSE_HELP);
             }
         }
     }
@@ -454,9 +459,9 @@ ModalAction render_frame(EditorState *ed, LayoutCache *cache, Font *fonts, float
             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 line%s",
-                              ed->dirty ? "*" : "", name, stats.byteSize / 1024.0,
-                              stats.lineCount, stats.lineCount == 1 ? "" : "s");
+            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));
             if (n > (int)sizeof tmp) n = (int)sizeof tmp;
             char *scratchBuf = scratch_alloc(n);
             memcpy(scratchBuf, tmp, (size_t)n);
diff --git a/src/test_main.c b/src/test_main.c
index 9e42c58..e39f806 100644
--- a/src/test_main.c
+++ b/src/test_main.c
@@ -6,6 +6,7 @@
 #include "inline_parse.h"
 #include "undo.h"
 #include "config.h"
+#include "lang.h"
 #include <stdio.h>
 #include <string.h>
 #include <stdlib.h>
@@ -412,6 +413,27 @@ static void test_config_parse_line(void) {
     config_free(&cfg);
 }

+static void test_lang_parse_line(void) {
+    CHECK(strcmp(lang_get(STR_HELP_CLOSE), "Close") == 0, "before loading anything, lang_get returns the built-in English default");
+
+    CHECK(!lang_parse_line("# a comment", 11), "a comment line returns false");
+    CHECK(!lang_parse_line("", 0), "a blank line returns false");
+    CHECK(!lang_parse_line("unknown.key = foo", 18), "an unrecognized key returns false and does not crash");
+
+    const char *l1 = "help.close = St\xC3\xA4ng";
+    CHECK(lang_parse_line(l1, (int)strlen(l1)), "a recognized key returns true");
+    CHECK(strcmp(lang_get(STR_HELP_CLOSE), "St\xC3\xA4ng") == 0, "lang_get now returns the overridden value");
+    CHECK(strcmp(lang_get(STR_HELP_TITLE), "Keyboard shortcuts") == 0,
+          "a string not present in the override still falls back to its English default");
+
+    const char *l2 = "help.close=Fermer";
+    CHECK(lang_parse_line(l2, (int)strlen(l2)), "no spaces around '=' still parses");
+    CHECK(strcmp(lang_get(STR_HELP_CLOSE), "Fermer") == 0, "re-setting the same key overwrites the previous override");
+
+    lang_free();
+    CHECK(strcmp(lang_get(STR_HELP_CLOSE), "Close") == 0, "lang_free() clears overrides back to the English defaults");
+}
+
 int main(void) {
     test_heading_autoformat();
     test_bullet_list_continuation();
@@ -433,6 +455,7 @@ int main(void) {
     test_selection_replace_on_type();
     test_selection_replace_on_backspace_across_blocks();
     test_config_parse_line();
+    test_lang_parse_line();

     printf("\n%s (%d failure%s)\n", g_failures == 0 ? "ALL PASS" : "SOME FAILED", g_failures, g_failures == 1 ? "" : "s");
     return g_failures == 0 ? 0 : 1;