/* filedlg.c —— 常用小程序⑨：**打开一个文件看它的头几行**（第 4 层：`ui_*` 图形界面）
 * filedlg.c -- Everyday utility #9: **open a file and read its first lines** (layer 4: the `ui_*` interface)
 *
 * 干什么：一屏一个按钮。「选择文件」走 `ui_dlg_file_open`，拿到的是一个**沙箱相对路径**
 * What it does: one button. "Choose a file" goes through `ui_dlg_file_open`, which hands back a
 * （返回**字节数**，0 = 取消 / 没权限 / 这一端没有文件选择器），然后用 `fopen`/`fread` 把它读进一块
 * **sandbox-relative path** (the return value is the **byte count**; 0 = cancelled / no permission /
 * 扁平缓冲，显示**前若干行**与**文件大小**。
 * this end has no file picker). The file is then read into one flat buffer with `fopen`/`fread`, and
 * the first few lines plus the **file size** are shown.
 *
 * ## ⚠ 手机上才有 —— 桌面（vmlcli）上「没有文件选择器」才是**正确输出**
 * ## ⚠ Phone-only -- on the desktop (vmlcli) "no file picker" is the **correct output**
 *
 * 桌面脚手架是**无头进程**：没有 GUI 可以弹选择框，`ui_dlg_file_open` 一律返回 0。
 * The desktop scaffold is a **headless process**: there is no GUI to open a picker, so
 * 而 **0 不是错误** —— 用户点了取消、没给权限、这一端没有该能力，三者都是 0，程序照常继续。
 * `ui_dlg_file_open` always returns 0. That **0 is not an error**: a cancel, a missing permission and
 * 所以降级分支要**明说**是"这一端没有"，而不是留一块空白。
 * a missing capability all look alike, and the program carries on. The degraded branch therefore
 * **says** that this end lacks it instead of leaving a blank area.
 *
 * ## 桌面怎么看到"选了文件"那一半：宿主自带的输入脚本注入，**不用改一行源码**
 * ## Seeing the picked-file branch on the desktop: the host's input-script injection, **no source change**
 *
 *     printf '0 file calendar.c\n300 keydown 13\n360 keyup 13\n' > in.txt
 *     vmlcli filedlg.c --vml-home third_party/vml --input in.txt --timeout 40 --frame filedlg.png
 *
 * 第一条把"选择器返回的路径"喂给宿主（`CliVmlHost.InjectFile`），后两条**按一下按钮**（回车 13），
 * The first line feeds the host the path a picker would return (`CliVmlHost.InjectFile`); the other two
 * 于是那个文件真的被读出来、头几行真的被画在屏幕上 —— 换一个路径就是另一份内容。
 * press the button (virtual key 13 = Enter), so the file really is read and its first lines really are
 * drawn -- with another path you get another file.
 *
 * ## 踩到的坑（都是这一轮真撞上的，写在这里省得下一个人再撞）
 * ## Pitfalls (all hit for real while writing this file; recorded so the next person does not)
 *
 * ① ⚠ **`fopen` 的第二个参数是 `int`（0 只读），不是 `"r"`** —— `Lib/shared/src/file.c` 的签名是
 * ① ⚠ **`fopen`'s second argument is an `int` (0 = read), not `"r"`** -- the signature in
 *   `int fopen(const char *path, int mode)`，而 `stdio.h` 里声明的却是 `FILE* fopen(const char*,
 *   `Lib/shared/src/file.c` is `int fopen(const char *path, int mode)`, while `stdio.h` declares
 *   const char*)`。照 `stdio.h` 写 `fopen(p, "r")`，模式收到一个**指针值**，`#110` 回
 *   `FILE* fopen(const char*, const char*)`. Writing `fopen(p, "r")` per the header hands a **pointer**
 *   `-2`（`INVALID_PARAMETER`）**且一个字都不报**（实测）。所以本文件**不 include `stdio.h`**、
 *   to the mode and `#110` answers `-2` (`INVALID_PARAMETER`) **with no message at all** (measured).
 *   自己按真签名声明 —— 顺带避开 `fgets`（那个函数在库里面**没有实现**，链上去会报未定义）。
 *   This file therefore declares the real signatures itself, which also avoids `fgets` (it has **no
 *   implementation** in the library and would fail to link).
 * ② ✅ **文件大小现在直接用 `Lib/c/file.h` 的 `fsize(handle)`**（2026-10-07 起）：那个包装原先
 * ② ✅ **File size now uses `fsize(handle)` from `Lib/c/file.h`** (since 2026-10-07): the wrapper used
 *   只是把 `#114` 原样转发、**没把命令号填进寄存器**（实测读到的是垃圾），现已修好 ——
 *   to forward `#114` without putting the command number in R1 (measured as garbage); it is fixed now —
 *   判据见 `Examples/c/test/file_api.c`（逐项核对数值，且注入反证过）。
 *   criterion: `Examples/c/test/file_api.c` (value-by-value, with a counter-check by injection).
 * ③ ⚠ **文件级标量别用负值初值**（本仓刚归档的 E187）：`static int x = -1;` 读到的是 **0**。
 * ③ ⚠ **Never give a file-scope scalar a negative initializer** (this repo's E187): `static int x = -1;`
 *   所以下面所有哨兵量一律用**正数**（0 = 没有/未知，1 = 有），别拿 -1 当"还没有"。
 *   reads back as **0**. Every sentinel below is a **positive** number (0 = absent/unknown, 1 = present).
 * ④ ⚠ **切行是"原地改字"**：`\n` 换成 `0` 之后，`buf` 同时是"整个文件"和"每行一个串"，
 * ④ ⚠ **Splitting lines rewrites the buffer in place**: after every `\n` becomes `0`, one buffer is
 *   省掉一整张行表（本仓不许二维数组 / 指针数组）。代价是**切完就回不去了** ——
 *   both the whole file and a list of strings, with no line table at all (this repo forbids 2-D and
 *   `\n` 数一遍要放在切之前（见 `splitLines`）。
 *   pointer arrays). The price is that the split is destructive, so the line count must be taken
 *   **before** it (see `splitLines`).
 * ⑤ ⚠ **"这一端没有"每拍都要重问一次**（与 `level.c` 坑 ② 同源）：`--input` 的事件由另一个线程按
 * ⑤ ⚠ **"This end has it" must be re-asked every tick** (same as pitfall 2 in `level.c`): the `--input`
 *   时刻投递，程序可能在注入落地之前就点过按钮了。所以按钮**一直可点**，点一次问一次。
 *   events arrive from another thread on a timer, so the button may be pressed before the injection
 *   lands. The button therefore stays live and every press asks again.
 * ⑥ ⚠ **裁行要裁"拷出来的那一份"，绝不能就地写 0**（这一版第一稿就是这么错的，看图才发现）：
 * ⑥ ⚠ **Clip a copy, never write a `0` into the line itself** (the first draft did, and only the
 *   太长的行就地截断之后，`lineAt` 从那个 0 往后走会拿到**上一行的尾巴** —— 屏幕上是"从第 2 行起
 *   picture showed it): once a long line is truncated in place, `lineAt` walks past that `0` and hands
 *   每行都串位、后半截是一排空框"（那半截是 UTF-8 的残字节，一个字节画一个空框）。
 *   back the **tail of the previous line**, so from the second row on every line is shifted and its
 *   ⇒ 判据：**"行内容串位"+"空框"同时出现**，就是这条。
 *   second half is a row of empty boxes (each box is one leftover UTF-8 byte). The signature to look
 *   for is shifted content **and** empty boxes at the same time.
 * ⑦ ⚠ **宽度按列数算，不按字符数**：一个汉字占 2 列（3 个字节），按"字符数"截会把 CJK 行切成
 * ⑦ ⚠ **Width is counted in columns, not characters**: a CJK glyph is two columns (three bytes), so
 *   两倍宽、右边顶出画布。`copyLine` 里按"首字节算 2、续字节算 0"数（见 `weather.c` 的 tty 80 列那条）。
 *   counting characters makes a CJK line twice as wide and it runs off the canvas. `copyLine` counts
 *   two for a lead byte and zero for a continuation (the same idea as the 80-column rule in `weather.c`).
 * ⑧ ⚠ **截断不许把多字节字符切成半个字节**，而且后果比"最后一个字画错"严重得多：宿主读字符串时
 * ⑧ ⚠ **Never cut a multibyte character in half**, and the damage is far worse than one wrong glyph:
 *   先验 UTF-8（`VmlHostRuntime.Str`），**整条不合法就整条按 CP437 解** —— 于是**整行**变成乱码
 *   the host validates UTF-8 first (`VmlHostRuntime.Str`) and decodes the **whole** string as CP437
 *   （实测：末尾留了个光秃秃的 `E4`，`退出` 那行整行变成 `ΘÇÇσç║`，而它前面的 ASCII 一点事没有，
 *   when it is invalid, so the **entire line** turns to mojibake (measured: one bare trailing `E4`
 *   看着像"这一行的中文坏了"）。
 *   turned one Chinese word into mojibake while the ASCII around it stayed fine). `copyLine` therefore
 *   ⇒ `copyLine` 收尾时把末尾那个**不完整的多字节序列整个丢掉**（先回退续字节，再看首字节要求的
 *   gives back any **incomplete trailing sequence** (back off the continuations, then check how many
 *   字节数够不够）。
 *   bytes the lead byte asked for).
 *   ⚠ 这个收尾**自己也能写错**（第一版就错了）：判据必须是"**留下的**字节够不够"。
 *   ⚠ That give-back **has a bug of its own** (the first version had it): the test must ask whether the
 *   拿"回退了几次"当字节数，会把一个**完整**的三字节字符砍成光秃秃的首字节，症状与前一条一模一样，
 *   **kept** bytes are enough. Using "how many times did I back off" instead chops a complete 3-byte
 *   而原因正好相反（那次是**砍坏了一个好字符**）。所以两个量要分成两个变量：
 *   glyph down to a bare lead byte -- same symptom, opposite cause (a good character destroyed).
 *   `cut` 只用来**找**首字节，`tail` 才是"留下多少"（描述，不引原字，免得这行自己也算中文注释）。
 *   Two variables, then: `cut` only locates the lead byte, and `tail` is how much we keep.
 *
 * 版面：尺寸一律从宿主问（`ui_scr_get_w()/_h()`），颜色是真彩 `0xAARRGGBB`，画完必须 `ui_2d_present()`。
 * Layout: every size is asked of the host (`ui_scr_get_w()/_h()`), colours are true colour
 * 循环**必须有界**（例程要能被自动验收）：空闲 10 秒自己退出，`Esc`/`q` 也退出。
 * `0xAARRGGBB`, and the frame must end with `ui_2d_present()`. The loop **must be bounded** (samples
 * 写真正的程序时把 `IDLE_EXIT_MS` 去掉即可。
 * have to be automatically verifiable): it exits after 10 s of idling, and `Esc`/`q` quits too.
 */

#include <vml_ui.h>
#include <stdlib.h>   /* itoa --- digits to text via the library */
#include <file.h>      /* fopen 家族的真声明（`int mode`）+ `fsize` —— 本文件不再自己抄一遍 */

#define IDLE_EXIT_MS 10000        /*  空闲 10 秒自己退出（验收用） */
                                  /* idle exit after 10 s (for automated checks) */

#define PATH_CAP 200              /*  宿主写回来的路径缓冲区 */
                                  /* the buffer the host writes the path into */
#define READ_CAP 4096             /*  一次读进多少字节（扁平缓冲，一次读够） */
                                  /* how many bytes we read at once (one flat buffer) */
#define BTN_H 44
#define LINE_H 18                 /*  正文行高（字号 13 配 18 不挤） */
                                  /* body line height (font 13 fits comfortably in 18) */

/* ⚠ 这里原先**自己声明** `fopen` 家族（因为 `stdio.h` 的签名是错的、`file.h` 也对不上）。
 *   2026-10-07 起 `Lib/c/file.h` 已与实现逐字对齐 ⇒ 直接 `#include <file.h>` 即可，
 *   那份手抄的声明连同"不 include stdio.h 免得打架"的顾虑一起删掉。
 * These declarations used to be hand-copied because both `stdio.h` and `file.h` disagreed with the
 * implementation. Since 2026-10-07 `Lib/c/file.h` matches it verbatim, so `#include <file.h>` does. */

/* 配色（真彩 AA RRGGBB）*/
/* Colours (true colour AA RRGGBB) */
#define C_BG      0xFF0E1218
#define C_BOX     0xFF161B24
#define C_RING    0xFF3C4658
#define C_TITLE   0xFFE8E8F0
#define C_DIM     0xFF8A94A8
#define C_ERR     0xFFFF8A80     /*  没有这项能力 / 读不出来 */
                                 /* capability absent, or unreadable */
#define C_NAME    0xFF6FC3FF     /*  文件名 */
                                 /* the file name */
#define C_SIZE    0xFFFFD166     /*  文件大小 */
                                 /* the file size */
#define C_CODE    0xFFB8C4D8     /*  正文那几行 */
                                 /* the body lines */
#define C_BTN     0xFF233046
#define C_BTNHOT  0xFF2F4060

/* ── 全局状态 ───────────────────────────────────────────────────────── */
/* -- Global state -- */
static int SW;
static int SH;
static int g_pad;
static int lang;

static char g_path[PATH_CAP];     /*  宿主写回来的路径（空串 = 还没选）*/
                                 /* the path the host wrote back (empty = nothing chosen yet) */
static char g_buf[READ_CAP];      /*  **唯一**的那块缓冲：先是整个文件，切完行就是每行一个串 */
                                 /* the **one** buffer: the whole file first, then one string per line */
static int g_len;                 /*  真读进来多少字节 */
                                 /* how many bytes were really read */
static int g_lines;               /*  缓冲里有几行（切之前数的）*/
                                 /* how many lines the buffer holds (counted before splitting) */
static int g_truncated;           /*  1 = 文件比缓冲大，只读了一部分 */
                                 /* 1 = the file is larger than the buffer, so only part was read */
static int g_size;                /*  文件大小（字节；0 = 不知道）*/
                                 /* the file size in bytes (0 = unknown) */
static int g_open;                /*  1 = 手上有内容可画 */
                                 /* 1 = we hold content to draw */
static int g_missing;             /*  1 = 这一端**没有**文件选择器（上一次的结果）*/
                                 /* 1 = this end **has no** file picker (last attempt's result) */
static int g_unreadable;          /*  1 = 选了但读不出来 */
                                 /* 1 = chosen but unreadable */
static int g_asked;               /*  1 = 至少点过一次按钮 */
                                 /* 1 = the button has been pressed at least once */

/* 版面（画与命中判定**必须用同一份**）*/
/* Layout (drawing and hit testing **must share this one copy**) */
static int boxX;
static int boxY;
static int boxW;
static int boxH;
static int btnX;
static int btnY;
static int btnW;
static int btnH;
static int infoY;

/* ── 系统调用：文件大小 ─────────────────────────────────────────────── */
/* -- Syscall: the file size -- */

/* ⚠ 这里原先手写了一次 `#114 FileControl(handle, 0, 0)`（取文件大小），因为当时库里那个
 *   `fsize` **没有把命令号填进寄存器**（实测读到垃圾，见文件头坑 ②）。
 *   2026-10-07 那个包装已修好（`Lib/shared/src/file.c`，判据 `Examples/c/test/file_api.c`）
 *   ⇒ 这里改用 `#include <file.h>` 带来的 `fsize(handle)`。
 * This used to hand-roll `#114 FileControl(handle, 0, 0)` because the library's `fsize` never put the
 * command number into R1 (measuring as garbage). That wrapper was fixed on 2026-10-07, so `fsize` is used. */

/* ── 小工具 ─────────────────────────────────────────────────────────── */
/* -- Small helpers -- */

static char g_i10[16];
static char *numstr(int v) {
    itoa(v, g_i10);
    return g_i10;
}

static char g_base[64];
static char *baseName(void) {
    int i;
    int last;
    int n;
    last = -1;
    i = 0;
    while (g_path[i] != 0) {
        if (g_path[i] == '/') last = i;
        i = i + 1;
    }
    n = 0;
    i = last + 1;
    while (g_path[i] != 0 && n < 40) { g_base[n] = g_path[i]; i = i + 1; n = n + 1; }
    g_base[n] = 0;
    return g_base;
}

/* 原地切行：`\n` → `0`，顺手把 `\r` 也抹掉（CRLF 文件的每行末尾会多一个回车，
 * Split lines in place: `\n` becomes `0` and `\r` is stripped too (a CRLF file leaves a carriage
 * 留着的话行尾会多画一个看不见的方块）。返回实际切成几行。
 * return at the end of every line, which renders as a stray box). Returns how many lines were cut.
 */
static int splitLines(char *s, int len) {
    int i;
    int n;
    int start;
    n = 0;
    start = 0;
    for (i = 0; i < len; i++) {
        if (s[i] == '\n') {
            if (i > start && s[i - 1] == '\r') s[i - 1] = 0;
            s[i] = 0;
            n = n + 1;
            start = i + 1;
        }
    }
    if (len > start) { s[len] = 0; n = n + 1; }    /*  最后一行没有换行收尾 */
                                                   /* the last line has no trailing newline */
    return n;
}

/* 第 n 行（切完之后用）—— 一维缓冲里"往前走 n 个 0"就是第 n 行，不需要行表。
 * Line n (after splitting) -- walking past n zero bytes in the flat buffer is all it takes; no line table.
 * 扫不到就返回空串（调用方按空行处理）。
 * Returns an empty string when it runs out (the caller treats that as a blank line).
 */
static char *lineAt(char *s, int len, int n) {
    int i;
    int k;
    i = 0;
    k = 0;
    while (k < n) {
        while (i < len && s[i] != 0) i = i + 1;
        if (i >= len) return s + len;
        i = i + 1;
        k = k + 1;
    }
    return s + i;
}

/* 把一行按**显示宽度**拷进 `g_line`（`maxCols` 是列数上限；半角 1 列、全角 2 列）。
 * Copy one line into `g_line`, bounded by a **display width** (`maxCols`; half-width glyphs are one
 * ⚠ **绝不就地裁剪**（裁的是 `g_buf` 里那一行本身）—— 见文件头坑 ⑥：就地写 0 会把"下一行的起点"
 * column and full-width ones are two). **Never clip in place** (never touch the line inside `g_buf`):
 * 一起切断，`lineAt` 从那个 0 往后走，拿到的是**上一行的尾巴**，屏幕上表现为"每行都串位、
 * see pitfall 6 -- writing a `0` there also cuts off where the next line starts, so `lineAt` walks past
 * 后半截还都是空框"（实测就是这一版）。
 * it and hands back the **tail of the previous line**, which shows up as shifted lines whose second
 * half is a row of empty boxes.
 * ⚠ 回退过**续字节**（UTF-8 的 `10xxxxxx`）：切在汉字中间会留半个字符，画出来是一个空框
 * ⚠ Back off over **continuation bytes** (`10xxxxxx` in UTF-8): cutting inside a CJK glyph leaves half
 * （一个汉字 3 个字节，第 2、3 个都带这个前缀，所以整个字必须吐回去）。
 * a character, which renders as an empty box (a CJK glyph is 3 bytes and the 2nd and 3rd both carry
 * that prefix, so the whole glyph is given back).
 */
static char g_line[160];
static void copyLine(char *s, int maxCols) {
    int i;
    int n;
    int w;
    int cw;
    int cut;
    int tail;
    int need;
    int b;
    i = 0;
    n = 0;
    w = 0;
    while (s[i] != 0 && n < 150) {
        cw = 1;
        if ((s[i] & 0x80) != 0) {
            cw = ((s[i] & 0xC0) == 0xC0) ? 2 : 0;   /*  多字节**首字节**算两列；续字节不另算 */
                                                    /* a multibyte **lead** byte is two columns; continuations add nothing */
        }
        if (cw > 0 && w + cw > maxCols) break;
        g_line[n] = s[i];
        i = i + 1;
        n = n + 1;
        w = w + cw;
    }
    /* ⚠ **收尾必须吐回"半个字"**（见坑 ⑧）：宿主读字符串时先验 UTF-8，**不合法就整条按 CP437 解**
     * ⚠ **The tail must give back a half-cut glyph** (pitfall 8): the host validates UTF-8 first and
     *   ⇒ 末尾留一个光秃秃的**首字节**（`E4` 后面没有续字节），不只是最后一个字画错，
     *   decodes the **whole string** as CP437 when it is invalid, so a lone **lead byte** at the end
     *   而是**整行变成一串乱码**（实测那两个汉字变成四个拉丁字母加符号）。
     *   (`E4` with its continuations missing) does not just spoil the last glyph: the entire line turns
     *   into mojibake (measured: two Chinese glyphs became four Latin letters and symbols).
     */
    tail = n;
    cut = n;                             /*  cut 只用来**找**最后一个字符的首字节，别拿它当"留下多少" */
                                         /* cut only **locates** the last character's lead byte; it is not "how much we keep" */
    while (cut > 0 && (g_line[cut - 1] & 0xC0) == 0x80) cut = cut - 1;
    if (cut > 0) {
        b = g_line[cut - 1] & 0xFF;
        need = 1;
        if (b >= 0xF0) need = 4;
        else if (b >= 0xE0) need = 3;
        else if (b >= 0xC0) need = 2;
        /* ⚠ 判据是"**留下**的字节够不够"（`n - (cut - 1)` 才是这个字符的字节数）——
         * ⚠ The test is whether **the kept bytes** are enough (`n - (cut - 1)` is the character's byte
         *   写错成"cut 减了几次"会**把好字符砍掉**：实测 `E2 80 87`（三个字节都齐）被砍成单个 `E2`
         *   count); getting it wrong **chops a good character**: measured, a complete `E2 80 87` was cut
         *   ⇒ 整行退化成乱码（见坑 ⑧）。
         *   down to a bare `E2` and the whole line turned to mojibake (pitfall 8).
         */
        if (n - (cut - 1) < need) tail = cut - 1;
    }
    g_line[tail] = 0;
}

/* ── 选文件并读进来 ─────────────────────────────────────────────────── */
/* -- Choosing a file and reading it in -- */

static void openFile(void) {
    int n;
    int h;
    g_asked = 1;
    g_open = 0;
    g_missing = 0;
    g_unreadable = 0;
    g_len = 0;
    g_lines = 0;
    g_truncated = 0;
    g_size = 0;
    n = ui_dlg_file_open(g_path, PATH_CAP);
    if (n <= 0) {
        /* ⚠ **降级分支**：0 = 取消 / 没权限 / 这一端没有选择器，三者不区分（接口就是这么定的）。 */
        /* ⚠ **The degraded branch**: 0 means cancelled / no permission / no picker here, and the API
         * 界面上按"这一端没有"来说 —— 桌面上确实如此，手机上是用户自己取消。
         * does not tell them apart. The UI words it as "this end lacks it", which is exactly the
         * desktop's case and reads as a user cancel on the phone. */
        g_missing = 1;
        g_path[0] = 0;
        return;
    }
    g_path[n] = 0;
    h = fopen(g_path, 0);
    if (h < 0) {
        g_unreadable = 1;
        return;
    }
    g_size = fsize(h);                    /*  ⚠ 读之前先问大小（读完句柄位置就变了）*/
                                         /* ⚠ Ask for the size before reading (reading moves the handle) */
    g_len = fread(h, g_buf, READ_CAP - 1);
    fclose(h);
    if (g_len < 0) g_len = 0;
    if (g_len >= READ_CAP - 1) g_truncated = 1;
    g_buf[g_len] = 0;
    /* ⚠ **切完就回不去了**：所以行数由 `splitLines` 自己数着返回（切之前数一遍也一样，
     * ⚠ **The split is destructive**, so `splitLines` counts as it goes (counting beforehand would
     *   只是别在切之后再去数一遍 —— 那时缓冲里已经没有 `\n` 了）。
     *   be equivalent; counting afterwards would find no `\n` left at all).
     */
    g_lines = splitLines(g_buf, g_len);
    g_open = 1;
}

/* ── 版面 ───────────────────────────────────────────────────────────── */
/* -- Layout -- */

static void layout(void) {
    SW = ui_scr_get_w();
    SH = ui_scr_get_h();
    if (SW <= 0) SW = 360;
    if (SH <= 0) SH = 620;
    g_pad = SW / 18;
    if (g_pad < 10) g_pad = 10;

    btnH = BTN_H;
    btnW = SW - g_pad * 2;
    if (btnW > 280) btnW = 280;
    btnX = (SW - btnW) / 2;
    btnY = SH - g_pad - btnH;

    infoY = btnY - 44;                    /*  按钮之上两行：文件名 / 大小 */
                                          /* two lines above the button: name / size */
    boxX = g_pad;
    boxY = g_pad + 42;
    boxW = SW - g_pad * 2;
    boxH = infoY - boxY - 10;
    if (boxH < 80) boxH = 80;
}

/* 正文区能放下几行（**画与"还有多少行没显示"用同一个数**）*/
/* How many body lines fit (the **same number** drives drawing and the "lines left over" note) */
static int maxBodyLines(void) {
    int n;
    n = (boxH - 20) / LINE_H;
    if (n < 1) n = 1;
    return n;
}

/* ── 绘制 ───────────────────────────────────────────────────────────── */
/* -- Drawing -- */

static void drawBody(void) {
    int i;
    int n;
    int y;
    int maxCols;
    char *ln;
    char buf[64];
    if (g_open == 0) return;
    maxCols = (boxW - 24) / 7;             /*  字号 13：半角约 7px 一列 ⇒ 列数 = 可用宽 / 7 */
                                          /* font 13: about 7 px per half-width column, so columns = width / 7 */
    if (maxCols < 8) maxCols = 8;
    n = maxBodyLines();
    y = boxY + 12;
    for (i = 0; i < n; i++) {
        if (i >= g_lines) break;
        ln = lineAt(g_buf, g_len, i);
        copyLine(ln, maxCols);             /*  ⚠ **拷出来裁**，不碰 `g_buf` 里的那一行（见坑 ⑥）*/
                                          /* ⚠ Clip **a copy**, never the line inside `g_buf` (pitfall 6) */
        if (g_line[0] != 0) ui_2d_text(boxX + 12, y, g_line, C_CODE, 13, VML_ANCHOR_LEFT);
        y = y + LINE_H;
    }
    if (g_lines > n) {
        buf[0] = 0;
        strcat(buf, lang == 0 ? "还有 " : "and ");
        strcat(buf, numstr(g_lines - n));
        strcat(buf, lang == 0 ? " 行没显示" : " more lines below");
        ui_2d_text(boxX + 12, boxY + boxH - 20, buf, C_DIM, 11, VML_ANCHOR_LEFT);
    }
}

static void drawEmpty(void) {
    int cy;
    cy = boxY + boxH / 2;
    if (g_missing != 0) {
        ui_2d_text(SW / 2, cy - 24, lang == 0 ? "没有选到文件" : "no file chosen",
                   C_ERR, 17, VML_ANCHOR_CENTER);
        ui_2d_text(SW / 2, cy + 2, lang == 0 ? "（本机没有文件选择器；桌面上返回 0）"
                                             : "(this device has no file picker; the desktop returns 0)",
                   C_ERR, 12, VML_ANCHOR_CENTER);
        ui_2d_text(SW / 2, cy + 26, lang == 0 ? "0 不是错误：照常继续跑" : "0 is not an error: carry on",
                   C_DIM, 11, VML_ANCHOR_CENTER);
        return;
    }
    if (g_unreadable != 0) {
        ui_2d_text(SW / 2, cy - 18, lang == 0 ? "选了，但读不出来" : "chosen, but unreadable",
                   C_ERR, 17, VML_ANCHOR_CENTER);
        ui_2d_text(SW / 2, cy + 8, lang == 0 ? "（文件不存在，或路径落在沙箱之外）"
                                             : "(missing, or the path is outside the sandbox)",
                   C_DIM, 12, VML_ANCHOR_CENTER);
        return;
    }
    ui_2d_text(SW / 2, cy - 18, lang == 0 ? "点下面的按钮选一个文件" : "press the button to choose a file",
               C_DIM, 15, VML_ANCHOR_CENTER);
    ui_2d_text(SW / 2, cy + 8, lang == 0 ? "会显示它的头几行与文件大小" : "its first lines and its size will show",
               C_DIM, 12, VML_ANCHOR_CENTER);
}

static void drawInfo(void) {
    char buf[96];
    if (g_open == 0) return;
    buf[0] = 0;
    strcat(buf, lang == 0 ? "文件：" : "file: ");
    strcat(buf, baseName());
    ui_2d_text(SW / 2, infoY, buf, C_NAME, 13, VML_ANCHOR_CENTER);

    buf[0] = 0;
    if (g_truncated != 0) {
        strcat(buf, numstr(g_len));
        strcat(buf, lang == 0 ? " 字节（只读了开头这些）" : " bytes (only the first part was read)");
    } else {
        strcat(buf, numstr(g_size));
        strcat(buf, lang == 0 ? " 字节 / " : " bytes / ");
        strcat(buf, numstr(g_lines));
        strcat(buf, lang == 0 ? " 行" : " lines");
    }
    ui_2d_text(SW / 2, infoY + 20, buf, C_SIZE, 12, VML_ANCHOR_CENTER);
}

static void draw(void) {
    ui_2d_clear(C_BG);

    ui_2d_text_styled(SW / 2, g_pad, lang == 0 ? "文件选择" : "File dialog",
                      C_TITLE, 18, VML_ANCHOR_CENTER, VML_FONT_BOLD);
    if (g_open != 0) {
        ui_2d_text(SW / 2, g_pad + 24, lang == 0 ? "fread 读进一块扁平缓冲，原地切行" : "one flat buffer, split in place",
                   C_DIM, 11, VML_ANCHOR_CENTER);
    } else {
        ui_2d_text(SW / 2, g_pad + 24, lang == 0 ? "手机上才有选择器；桌面上会如实报「没有」"
                                                 : "phones have the picker; the desktop reports \"absent\"",
                   C_DIM, 11, VML_ANCHOR_CENTER);
    }

    ui_2d_rect(boxX, boxY, boxW, boxH, C_BOX, 1, 0, 8);
    ui_2d_rect(boxX, boxY, boxW, boxH, C_RING, 0, 1, 8);
    if (g_open != 0) drawBody();
    else drawEmpty();
    drawInfo();

    ui_2d_rect(btnX, btnY, btnW, btnH, C_BTN, 1, 0, btnH / 2);
    ui_2d_rect(btnX, btnY, btnW, btnH, C_BTNHOT, 0, 1, btnH / 2);
    ui_2d_text(SW / 2, btnY + (btnH - 15) / 2,
               lang == 0 ? "选择文件  Enter" : "Choose a file  Enter",
               C_TITLE, 15, VML_ANCHOR_CENTER);

    ui_2d_text(SW / 2, SH - 14, lang == 0 ? "回车 / 点按钮选中   Esc / q 退出"
                                          : "Enter or tap to choose   Esc / q quits",
               C_DIM, 11, VML_ANCHOR_CENTER);

    ui_2d_present();      /* ⚠ 不调这句 = 黑屏 */
                          /* without this call you get a black screen */
}

/* 点在选择按钮里吗（与 `draw` 用的是**同一组**坐标）*/
/* Did the tap land on the choose button (the **same** geometry `draw` uses) */
static int hitButton(int px, int py) {
    if (px >= btnX && px < btnX + btnW && py >= btnY && py < btnY + btnH) return 1;
    return 0;
}

/* ── 主流程 ─────────────────────────────────────────────────────────── */
/* -- Main flow -- */
int main(void) {
    int msg[4];
    int t;
    int t0;
    int done;

    lang = ui_scr_get_language();

    layout();
    ui_win_open_ex(lang == 0 ? "文件选择" : "File dialog",
                   0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    layout();                              /*  开窗之后画布尺寸才是准的，再算一次 */
                                          /* the canvas size is only final after the window opens */
    draw();

    t0 = ui_time_get_ms();
    done = 0;
    while (!done && ui_win_is_closed() == 0) {
        t = ui_msg_wait(msg, 200);
        if (t == VML_MSG_NONE) {
            if (ui_time_get_ms() - t0 > IDLE_EXIT_MS) done = 1;
            continue;
        }
        t0 = ui_time_get_ms();
        if (t == VML_MSG_KEYDOWN) {
            int k = msg[1];
            if (k == VML_KEY_ESCAPE || k == 'q' || k == 'Q') done = 1;
            else if (k == VML_KEY_ENTER || k == VML_KEY_SPACE) { openFile(); draw(); }
            continue;
        }
        if (t == VML_MSG_TOUCHDOWN || t == VML_MSG_MOUSEDOWN) {
            if (hitButton(msg[1], msg[2]) != 0) { openFile(); draw(); }
            continue;
        }
        if (t == VML_MSG_WINDOWORIENT || t == VML_MSG_WINDOWRESIZE) {
            layout();
            draw();
            continue;
        }
        if (t == VML_MSG_WINDOWCLOSE) done = 1;
    }
    return 0;
}
