/* todo.c —— 常用小程序⑤：**待办清单**（第 4 层：`ui_*` 图形界面）
 * todo.c -- Everyday utility #5: a **todo list** (layer 4: the `ui_*` graphical interface)
 *
 * 干什么：一屏待办 —— 顶部输入行加一条，下面一行一条。
 * What it does: one screen of todos -- a box at the top adds one, one row per item below.
 * 点一条 = 在"完成 / 未完成"之间切换（完成后变灰 + 划一道删除线）。
 * A tap on a row toggles done/undone (a done row turns grey and gets a strikethrough).
 * 点右侧的 ✕ 或**长按那一行** = 删除它。
 * A tap on the ✕ at the right, or a **long press on the row**, deletes it.
 * 底部一行统计"共 N 条 / 未完成 M 条"。
 * The footer counts "N total / M open".
 * `Esc` / `q` 退出，空闲 10 秒也退出（例程要能被自动验收）。
 * `Esc` / `q` quits, and ten idle seconds quit too (samples must be verifiable automatically).
 *
 * 怎么加一条 —— **两条路都有，别只留一条**
 * How to add one -- **both paths exist, do not keep only one**
 *
 * · **对话框**：点顶部右边的 ＋，或按 `Tab` → `ui_dlg_input` 弹一个输入框，回车即成。
 * · **Dialog**: tap the + at the right of the input row, or press `Tab`; `ui_dlg_input` opens a box.
 * · **直接打字**：可打印 ASCII 落在输入行里，`退格`删，`回车`添加。
 * · **Just type**: printable ASCII lands in the input row; Backspace deletes, Enter adds.
 *   手机上没有 `Tab` 键，所以那条路只能靠 ＋ 按钮；桌面上两条都在。
 *   A phone has no `Tab` key, so the button is the only road there; the desktop has both.
 *   ⚠ 两条路是**同一个入口**（都落到 `add_text`），不是两套逻辑。
 *   ⚠ Both are **one entry point** (both end in `add_text`), not two copies of the logic.
 *
 * 数据存在哪 —— `ui_store_*`（宿主键值库，重启后还在）
 * Where the data lives -- `ui_store_*` (the host key/value store; it survives a restart)
 *
 * · `todo.count` = 条数；`todo.0` … `todo.<n-1>` = 每条一项。
 * · `todo.count` is the item count; `todo.0` ... `todo.<n-1>` are the items.
 * · 每条的值是 `done|正文`，例如 `1|买牛奶`（`0` = 未完成、`1` = 已完成）。
 * · Each value is `done|text`, for example `1|buy milk` (0 = open, 1 = done).
 * · 宿主会给键统一加 `vml.` 前缀，所以不会和 App 自己的设置打架。
 * · The host prefixes every key with `vml.`, so these cannot clash with the App's own settings.
 * · 删除一条之后**多余的旧键要主动删掉**（`ui_store_del`），不然下次载入会把它们读回来。
 * · After a delete the **leftover keys must be removed** (`ui_store_del`), or the next load reads them back.
 *
 * 踩过的坑（都是实测出来的，不是推的）
 * Pitfalls (all measured, not guessed)
 *
 * ① `ui_store_set` **没有返回值**（声明就是 `void`），当 `int` 用会编不过。
 * ① `ui_store_set` **returns nothing** (it is declared `void`); using it as an `int` will not compile.
 *    `ui_store_get` 才返回长度，而且**键不存在时返回 -1**（不是 0）。
 *    `ui_store_get` is the one that returns a length, and it returns **-1 when the key is absent** (not 0).
 * ② `ui_2d_text` 的 `y` 是**基线**（墨迹在 y 的**上方**），不是"字盒的顶"。
 * ② The `y` of `ui_2d_text` is the **baseline** (the ink sits **above** y), not the top of the glyph box.
 *    按"y 是盒顶"排版，文字会整体下沉半行、行的下沿被卡片切掉。
 *    Lay out as if y were the top and every line sinks half a line, clipping on the card's lower edge.
 * ③ `VML_KEY_LEFT/UP/RIGHT/DOWN` = 37/38/39/40，**全都落在可打印 ASCII 32..126 里**。
 * ③ `VML_KEY_LEFT/UP/RIGHT/DOWN` are 37/38/39/40, **all inside printable ASCII 32..126**.
 *    所以判按键时**特殊键必须排在"可打印"之前**，否则按一下方向键会往输入行里塞一个 `%`。
 *    So the special keys **must come before the printable range**, or an arrow key types a literal `%`.
 * ④ **画与命中判定必须共用同一套几何**（`row_rect` / `del_rect` / `add_rect`）。
 * ④ **Drawing and hit testing must share one set of geometry** (`row_rect` / `del_rect` / `add_rect`).
 *    各算一套就会出现"看着点在第三行、实际改了第四行"这种老毛病。
 *    Two copies of the arithmetic produce the classic "it looked like row three but row four changed".
 * ⑤ **一个汉字是 3 个字节**：正文按字节切会在行中间劈开一个字，屏幕上就是一堆豆腐块。
 * ⑤ **One CJK glyph is 3 bytes**: cutting text by byte splits a glyph in half and draws tofu boxes.
 *    所以截断走 `clip_emit`：**按 UTF-8 序列**走，放不下就整字不画。
 *    So truncation goes through `clip_emit`, which walks **whole UTF-8 sequences** and drops a glyph
 *    rather than half of one.
 *
 * 跑法（桌面脚手架，秒级，别为改一行去打 APK）
 * How to run (desktop scaffolding, seconds -- do not build an APK just to change one line)
 *
 *     dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll Examples/c/app/todo.c \
 *         --vml-home third_party/vml --store /tmp/todo-store.json --timeout 40
 * 手机上：命令行页敲 `vml run examples/c/app/todo.c`。
 * On the phone: type `vml run examples/c/app/todo.c` in the command-line page.
 * ⚠ 桌面上不给 `--store` 时键值库**只在本次进程内**（跑完就没），跨运行持久化要显式给路径。
 * ⚠ Without `--store` the desktop key/value store is **process-local**; pass a path to persist across runs.
 */

#include <vml_ui.h>
#include <stdlib.h>   /* itoa --- digits to text via the library */

/* 空闲这么久就自己退出（例程要能被自动验收）*/
/* Idle this long and it quits, so the sample can be verified. */
#define IDLE_EXIT_MS  10000

/* 容量与键码：最多几条 / 每条多少字节 / 输入行 / Tab 键 / 长按阈值 */
/* Capacities and key codes: how many items, bytes per item, the input row, Tab, long press. */
#define MAXITEM       32
#define TEXTLEN       60
#define INPCAP        96
#define VK_TAB        9
#define LONG_PRESS_MS 600

/* 配色（真彩 AARRGGBB，第 4 层没有索引色）*/
/* Colours (true colour AARRGGBB; layer 4 has no palette). */
#define C_BG     0xFF101018
#define C_CARD   0xFF1A1A24
#define C_BOX    0xFF2A3038
#define C_TITLE  0xFFE8E8F0
#define C_TEXT   0xFFD8DCE8
#define C_DONE   0xFF6A7080
#define C_DIM    0xFF9AA0B0
#define C_ACCENT 0xFF4A90D9
#define C_OK     0xFF3FAE6A
#define C_DEL    0xFFE05A4A
/* 后三个是状态色：已完成 = 灰、勾选 = 绿、删除 = 红 */
/* The last three are the state colours: done grey, the tick green, delete red. */

/* ── 状态 ───────────────────────────────────────────────────────────── */
/* -- State -- */
/* 版面：全部由宿主给的尺寸算出来（**不写死坐标**，见文件头）*/
/* Layout: every value comes from the size the host reports, never hard-coded. */
static int SW;
static int SH;
static int lang;
static int pad;
static int fs;
static int inp_y;
static int inp_h;
static int add_x;
static int add_w;
static int list_top;
static int list_bot;
static int row_h;
static int t_vis;
static int foot_y;

/* `lang` 是界面语言（0 = 中文，1 = 英文）；`add_x`/`add_w` 是右边那颗 ＋ 按钮 */
/* `lang` is the UI language (0 Chinese, 1 English); `add_x`/`add_w` are the + button. */

/* ── 数据 ───────────────────────────────────────────────────────────── */
/* -- The data -- */
/* 一律**一维扁平缓冲**：不用二维数组、不用指针数组、不取数组元素的地址（`&arr[i]`）*/
/* Everything is **one flat buffer**: no 2-D arrays, no pointer arrays, no address-of an element. */
/* 第 i 条的正文就在 `t_buf + i * TEXTLEN` 上，取法是 `item(i)` */
/* Item i's text lives at `t_buf + i * TEXTLEN` and is reached through `item(i)`. */
static char t_buf[MAXITEM * TEXTLEN];
static int  t_done[MAXITEM];
static int  t_n;
static int  t_top;

static char inp[INPCAP];
static int  inp_len;

/* 按下的跟踪：落在第几条 / 是不是删除区 / 什么时候按的 */
/* The press tracking: which row, whether it was the delete zone, and when. */
static int  press_i;
static int  press_del;
static int  press_ms;

/* ── 小工具 ─────────────────────────────────────────────────────────── */
/* -- Small helpers -- */

/* 第 i 条的正文指针（不取元素地址，只做指针加法，照 stock.c 的扁平缓冲写法）
 * A pointer to the text of item i (pointer arithmetic only, the flat-buffer style of stock.c) */
static char *item(int i) { return t_buf + i * TEXTLEN; }

/* 整数 → 十进制串。返回**同一个静态缓冲**，别在一个表达式里连用两次。
 * Integer to a decimal string. Returns **one static buffer**; never use it twice in one expression. */
static char numbuf[16];
static char *numstr(int v) {
    itoa(v, numbuf);
    return numbuf;
}

/* 十进制串 → 整数（认不出回 0）。只给 `todo.count` 用。
 * Decimal text to an integer (0 when it cannot be read). Only `todo.count` needs it. */
static int parse_int(char *s) {
    int i = 0;
    int v = 0;
    int neg = 0;
    while (s[i] == ' ' || s[i] == '\t') i = i + 1;
    if (s[i] == '-') { neg = 1; i = i + 1; }
    while (s[i] >= '0' && s[i] <= '9') { v = v * 10 + (s[i] - '0'); i = i + 1; }
    return neg ? 0 - v : v;
}

/* 抄一个串（补 0），边界自己钳 —— 前端没有 `strncpy` 那么好用的东西时这就是它。
 * Copy a string (NUL-terminated) with the bound clamped here -- this is `strncpy` where the front end
 * does not have one. */
static void copy_str(char *dst, char *src, int cap) {
    int i = 0;
    while (src[i] != 0 && i < cap - 1) { dst[i] = src[i]; i = i + 1; }
    dst[i] = 0;
}

/* 这个字节起头是几个字节的 UTF-8 序列（认不出按 1 算）
 * How many bytes the UTF-8 sequence starting here is (unrecognized counts as 1) */
static int utf8_len(int lead) {
    if (lead < 0x80) return 1;
    if ((lead & 0xE0) == 0xC0) return 2;
    if ((lead & 0xF0) == 0xE0) return 3;
    if ((lead & 0xF8) == 0xF0) return 4;
    return 1;
}

/* 一个字符在 `size` 号字下大约占几像素：ASCII 与拉丁扩展 6/10 个字号，CJK/emoji 全宽。
 * Roughly how many pixels one character takes at font size `size`: ASCII and the Latin blocks 6/10 of
 * the size, CJK/emoji full width. */
static int char_px(int lead, int size) {
    if (lead < 0x80) return (size * 6) / 10;
    if ((lead & 0xE0) == 0xC0) return (size * 6) / 10;
    return size;
}

/* 把 `s` 里"放得下 `max_px` 像素"的前缀整字抄进 `out`，返回实际占宽。
 * Copy the prefix of `s` that fits into `max_px` pixels into `out`, whole glyphs only; returns the
 * width actually used.
 * ⚠ **按 UTF-8 序列走**（一个汉字 3 字节，按字节切会劈成两半 => 豆腐块，见文件头 ⑤）。
 * ⚠ It walks **whole UTF-8 sequences** (a CJK glyph is 3 bytes; cutting by byte splits it in half and
 *   draws tofu -- pitfall 5 in the header).
 * 返回的宽度与"删除线画多长"**共用同一个算式** —— 各算一套，线就会比字长一截或短一截。
 * The returned width is the **same formula** the strikethrough uses; two copies and the line ends up
 * longer or shorter than the text.
 */
static int clip_emit(char *s, int max_px, int size, char *out, int cap) {
    int i = 0;
    int o = 0;
    int w = 0;
    int n;
    int cw;
    int k;
    while (s[i] != 0) {
        n = utf8_len(s[i] & 0xFF);
        cw = char_px(s[i] & 0xFF, size);
        if (w + cw > max_px) break;
        if (o + n > cap - 1) break;
        k = 0;
        while (k < n) { out[o + k] = s[i + k]; k = k + 1; }
        o = o + n;
        i = i + n;
        w = w + cw;
    }
    out[o] = 0;
    return w;
}

/* ── 版式：几何只有一个真源 ─────────────────────────────────────────── */
/* -- Layout: one single source for the geometry -- */

/* ＋ 按钮的矩形（画与命中判定共用）
 * The rectangle of the + button (shared by drawing and hit testing) */
static void add_rect(int *rx, int *ry, int *rw, int *rh) {
    *rx = add_x;
    *ry = inp_y;
    *rw = add_w;
    *rh = inp_h;
}

/* 屏幕上第 k 个可见行（条目 `t_top + k`）的矩形
 * The rectangle of visible row k (that is item `t_top + k`) */
static void row_rect(int k, int *rx, int *ry, int *rw, int *rh) {
    *rx = pad;
    *ry = list_top + k * row_h;
    *rw = SW - pad * 2;
    *rh = row_h - 6;                       /* 条与条之间留 6 像素 */
    /* six pixels between cards */
}

/* 一行的删除区（右侧那个 ✕ 的方框）
 * The delete zone of a row (the box holding the ✕ on the right) */
static void del_rect(int k, int *rx, int *ry, int *rw, int *rh) {
    int x;
    int y;
    int w;
    int h;
    row_rect(k, &x, &y, &w, &h);
    *rw = 28;
    *rh = 28;
    *rx = x + w - 12 - *rw;
    *ry = y + (h - *rh) / 2;
}

static void layout(void) {
    SW = ui_scr_get_w();
    SH = ui_scr_get_h();
    if (SW <= 0) SW = 360;      /* 宿主答不上来时的兜底 */
    /* fallback when the host cannot answer */
    if (SH <= 0) SH = 620;
    pad = SW / 20;
    if (pad < 8) pad = 8;

    fs = SW / 26;               /* 字号随宽度走：手机窄屏小一点，桌面大一点 */
    if (fs < 12) fs = 12;       /* the font follows the width: smaller on a narrow phone */
    if (fs > 19) fs = 19;

    inp_h = fs + 22;
    inp_y = pad + fs + 22;
    add_w = inp_h;
    add_x = SW - pad - add_w;

    row_h = fs + 34;
    list_top = inp_y + inp_h + 12;
    foot_y = SH - pad - 42;
    list_bot = foot_y - 24;

    t_vis = (list_bot - list_top) / row_h;
    if (t_vis < 1) t_vis = 1;
    if (t_vis > 14) t_vis = 14;
    clamp_top();
}

/* 把滚动位置夹回合法范围（列表尾部别滚出空白）
 * Clamp the scroll position back into range (do not scroll past the tail into blank space) */
static void clamp_top(void) {
    if (t_top > t_n - t_vis) t_top = t_n - t_vis;
    if (t_top < 0) t_top = 0;
}

/* 让**最后一条**露出来（刚加完一条时调它）
 * Bring the **last item** into view (called right after an add) */
static void scroll_to_end(void) {
    if (t_n > t_vis) t_top = t_n - t_vis; else t_top = 0;
    clamp_top();
}

/* ── 数据 ───────────────────────────────────────────────────────────── */
/* -- The data -- */

/* 删掉第 i 条（后面的整体前移一位）
 * Delete item i (everything after it shifts one up) */
static void del_item(int i) {
    int k;
    if (i < 0 || i >= t_n) return;
    k = i;
    while (k < t_n - 1) {
        copy_str(item(k), item(k + 1), TEXTLEN);
        t_done[k] = t_done[k + 1];
        k = k + 1;
    }
    t_n = t_n - 1;
    clamp_top();
}

/* 去掉首尾空格（顺便挡住"只打了几个空格就回车"）
 * Trim the spaces at both ends (which also blocks "pressed Enter after typing only spaces").
 * ⚠ 原地搬（`src >= dst` 恒成立），不另开缓冲 —— 一个缓冲两处用迟早会写歪。
 * ⚠ The shift is in place (`src >= dst` always holds); a second buffer used twice is a trap. */
static void trim_in_place(char *s) {
    int i = 0;
    int o = 0;
    while (s[i] == ' ' || s[i] == '\t') i = i + 1;
    while (s[i] != 0) { s[o] = s[i]; o = o + 1; i = i + 1; }
    s[o] = 0;
    while (o > 0 && (s[o - 1] == ' ' || s[o - 1] == '\t')) { o = o - 1; s[o] = 0; }
}

/* 加一条。返回 1 = 真加了，0 = 空串或列表满了（**两条路都走这里**，见文件头）
 * Add one item. Returns 1 when it was added, 0 for an empty string or a full list (both paths land
 * here -- see the file header). */
static int add_text(char *s) {
    int w;
    char tmp[TEXTLEN];
    if (t_n >= MAXITEM) return 0;
    copy_str(tmp, s, TEXTLEN);
    trim_in_place(tmp);
    if (tmp[0] == 0) return 0;
    w = 0;
    while (tmp[w] != 0) w = w + 1;
    if (w > TEXTLEN - 1) w = TEXTLEN - 1;      /* 拷贝时已钳过，这里只是再钉一遍 */
                                               /* the copy already clamped it; this pins it again */
    copy_str(item(t_n), tmp, TEXTLEN);
    t_done[t_n] = 0;
    t_n = t_n + 1;
    scroll_to_end();
    return 1;
}

/* 存回去：先写条数，再逐条写 `done|正文`，最后把**多出来的旧键删掉**。
 * Save: the count first, then one `done|text` per item, and finally the **leftover keys are deleted**.
 * ⚠ 不删旧键的话，删过条目之后下次载入会把幽灵条目读回来（实测：删了还剩下）。
 * ⚠ Without that last step a deletion leaves ghosts behind (measured: the item came back next run).
 * ⚠ `ui_store_set` 是 **void**（见文件头 ①），所以这里没有"写成功了吗"这一问。
 * ⚠ `ui_store_set` is **void** (pitfall 1), so there is no "did it write" question to ask here.
 */
static void save_all(void) {
    char key[24];
    char val[TEXTLEN + 4];
    int i;
    key[0] = 0;
    strcat(key, "todo.count");
    ui_store_set(key, numstr(t_n));
    i = 0;
    while (i < t_n) {
        key[0] = 0;
        strcat(key, "todo.");
        strcat(key, numstr(i));
        val[0] = t_done[i] ? '1' : '0';
        val[1] = '|';
        val[2] = 0;
        strcat(val, item(i));
        ui_store_set(key, val);
        i = i + 1;
    }
    i = t_n;
    while (i < MAXITEM) {
        key[0] = 0;
        strcat(key, "todo.");
        strcat(key, numstr(i));
        ui_store_del(key);
        i = i + 1;
    }
}

static void load_all(void) {
    char key[24];
    char buf[TEXTLEN + 4];
    int n;
    int i;
    int k;
    t_n = 0;
    t_top = 0;
    key[0] = 0;
    strcat(key, "todo.count");
    n = ui_store_get(key, buf, TEXTLEN + 4);
    if (n <= 0) return;                    /* 没存过（-1）或存了个空的 */
    /* never saved (-1) or saved empty */
    k = parse_int(buf);
    if (k < 0) k = 0;
    if (k > MAXITEM) k = MAXITEM;
    i = 0;
    while (i < k) {
        key[0] = 0;
        strcat(key, "todo.");
        strcat(key, numstr(i));
        n = ui_store_get(key, buf, TEXTLEN + 4);
        /* 形状不对（没有 `done|` 那一段）就整条丢掉 —— 宁可少一条，不要一条读歪的 */
        /* A malformed value (no `done|` part) is dropped whole: better one short than one wrong */
        if (n > 0 && (buf[0] == '0' || buf[0] == '1') && buf[1] == '|') {
            t_done[t_n] = buf[0] - '0';
            copy_str(item(t_n), buf + 2, TEXTLEN);
            t_n = t_n + 1;
        }
        i = i + 1;
    }
}

/* ── 绘制 ───────────────────────────────────────────────────────────── */
/* -- Drawing -- */

/* 画一个"✕"：**用两条线画，不用字形** —— 字形要看字体有没有那个码位，线一定画得出。
 * Draw an x: **two lines rather than a glyph** -- a glyph depends on the font having that codepoint.
 * 同理 ＋ 与 ✓ 都是线画的。
 * The + and the tick are drawn the same way. */
static void cross_at(int cx, int cy, int r, int color) {
    ui_2d_line(cx - r, cy - r, cx + r, cy + r, color, 2);
    ui_2d_line(cx - r, cy + r, cx + r, cy - r, color, 2);
}

static void plus_at(int cx, int cy, int r, int color) {
    ui_2d_line(cx - r, cy, cx + r, cy, color, 2);
    ui_2d_line(cx, cy - r, cx, cy + r, color, 2);
}

static void tick_at(int cx, int cy, int color) {
    ui_2d_line(cx - 5, cy, cx - 1, cy + 4, color, 2);
    ui_2d_line(cx - 1, cy + 4, cx + 5, cy - 4, color, 2);
}

static void draw(void) {
    int i;
    int k;
    int x;
    int y;
    int w;
    int h;
    int base;
    int tx;
    int avail;
    int tw;
    int ck;
    int cs;
    char tb[64];
    char clip[TEXTLEN + 4];
    int done_n;

    ui_2d_clear(C_BG);

    /* 标题：左边名字，右边条数
     * Title: the name on the left, the count on the right */
    ui_2d_text_styled(pad, pad + fs, lang == 0 ? "待办清单" : "Todo List",
                      C_TITLE, fs + 5, VML_ANCHOR_LEFT, VML_FONT_BOLD);
    tb[0] = 0;
    strcat(tb, numstr(t_n));
    strcat(tb, "/");
    strcat(tb, numstr(MAXITEM));
    ui_2d_text(SW - pad, pad + fs, tb, C_DIM, fs - 2, VML_ANCHOR_RIGHT);

    /* 输入行：一个圆角框 + 输入的文字（空的时候是一行提示）
     * The input row: a rounded box plus what has been typed (a hint while it is empty) */
    ui_2d_rect(pad, inp_y, add_x - pad - 8, inp_h, C_CARD, 1, 0, 8);
    base = inp_y + inp_h / 2 + (fs * 35) / 100;
    if (inp_len > 0) {
        copy_str(clip, inp, INPCAP);
        ui_2d_text(pad + 12, base, clip, C_TEXT, fs, VML_ANCHOR_LEFT);
        /* 光标：一条竖条钉在文字后面（宽度自己算，别去问字体）
         * The caret: a bar right after the text (its width is computed here, never asked of the font) */
        tw = 0;
        i = 0;
        while (inp[i] != 0) { tw = tw + char_px(inp[i] & 0xFF, fs); i = i + utf8_len(inp[i] & 0xFF); }
        ui_2d_rect(pad + 12 + tw + 1, base - fs + 1, 2, fs + 3, C_ACCENT, 1, 0, 0);
    } else {
        ui_2d_text(pad + 12, base, lang == 0 ? "按 Tab 弹输入框，或直接打字"
                                             : "Tab opens the box, or just type",
                   C_DIM, fs - 2, VML_ANCHOR_LEFT);
    }

    /* ＋ 按钮 */
    /* the + button */
    add_rect(&x, &y, &w, &h);
    ui_2d_rect(x, y, w, h, C_ACCENT, 1, 0, 8);
    plus_at(x + w / 2, y + h / 2, 7, 0xFF101018);

    /* 列表 */
    /* the list */
    if (t_n == 0) {
        ui_2d_text(SW / 2, (list_top + list_bot) / 2,
                   lang == 0 ? "还没有待办 —— 点 ＋ 或按 Tab 加一条"
                             : "no items yet - tap + or press Tab",
                   C_DIM, fs, VML_ANCHOR_CENTER);
    }
    k = 0;
    while (k < t_vis && t_top + k < t_n) {
        i = t_top + k;
        row_rect(k, &x, &y, &w, &h);
        ui_2d_rect(x, y, w, h, C_CARD, 1, 0, 10);

        /* 勾选框 */
        /* the tick box */
        cs = 22;
        ck = x + 12;
        ui_2d_rect(ck, y + (h - cs) / 2, cs, cs, t_done[i] ? C_OK : C_BOX, 1, 0, 6);
        if (t_done[i]) tick_at(ck + cs / 2, y + h / 2, 0xFF101018);

        /* 正文：**放不下就整字不画**（`clip_emit`），别让它压到右边的 ✕ 上 */
        /* The text: whole glyphs only (`clip_emit`), so it never runs under the x on the right */
        tx = ck + cs + 12;
        avail = (x + w - 12 - 28) - tx - 10;
        if (avail < 20) avail = 20;
        tw = clip_emit(item(i), avail, fs, clip, TEXTLEN + 4);
        base = y + h / 2 + (fs * 35) / 100;
        ui_2d_text(tx, base, clip, t_done[i] ? C_DONE : C_TEXT, fs, VML_ANCHOR_LEFT);
        if (t_done[i] != 0) {
            /* 删除线：横在墨迹中间（`base` 是基线，往上抬 1/3 个字高正好落在字腰上）*/
            /* Strikethrough through the middle of the ink (the baseline lifted by a third of the size) */
            ui_2d_line(tx, base - (fs * 33) / 100, tx + tw, base - (fs * 33) / 100, C_DONE, 2);
        }

        /* 删除区 */
        /* the delete zone */
        del_rect(k, &x, &y, &w, &h);
        ui_2d_rect(x, y, w, h, C_BOX, 1, 0, 6);
        cross_at(x + w / 2, y + h / 2, 6, C_DEL);

        k = k + 1;
    }

    /* 底部：统计 + 操作提示 */
    /* Footer: the tally plus the controls */
    done_n = 0;
    i = 0;
    while (i < t_n) { if (t_done[i] != 0) done_n = done_n + 1; i = i + 1; }
    tb[0] = 0;
    if (lang == 0) {
        strcat(tb, "共 ");
        strcat(tb, numstr(t_n));
        strcat(tb, " 条 · 未完成 ");
        strcat(tb, numstr(t_n - done_n));
        strcat(tb, " 条");
    } else {
        strcat(tb, numstr(t_n));
        strcat(tb, " total · ");
        strcat(tb, numstr(t_n - done_n));
        strcat(tb, " open");
    }
    ui_2d_text(SW / 2, foot_y, tb, C_DIM, fs - 1, VML_ANCHOR_CENTER);
    ui_2d_text(SW / 2, foot_y + (fs + 6),
               lang == 0 ? "点一条=完成 · 长按=删除 · Tab 输入 · Esc 退出"
                         : "tap=done · long press=delete · Tab=type · Esc=quit",
               C_DIM, fs - 4, VML_ANCHOR_CENTER);

    ui_2d_present();      /* ⚠ 不调这句 = 黑屏 */
    /* without this call you get a black screen */
}

/* ── 输入 ───────────────────────────────────────────────────────────── */
/* -- Input -- */

/* 往输入行塞一个可打印 ASCII 字符；满了就丢掉（别把已经打的字挤掉）
 * Push one printable ASCII character into the input row; a full buffer drops it. */
static void inp_push(int ch) {
    if (inp_len >= INPCAP - 1) return;
    inp[inp_len] = (char)ch;
    inp_len = inp_len + 1;
    inp[inp_len] = 0;
}

/* 退格：删掉输入行的**最后一个字符**（跨过整个 UTF-8 序列，别把一个汉字劈开）
 * Backspace: drop the **last character** of the input row (stepping over the whole UTF-8 sequence). */
static void inp_back(void) {
    int i;
    if (inp_len <= 0) return;
    i = inp_len - 1;
    while (i > 0 && ((inp[i] & 0xFF) & 0xC0) == 0x80) i = i - 1;
    inp_len = i;
    inp[i] = 0;
}

/* 回车：把输入行变成一个条目。返回 1 = 真加了
 * Enter: turn the input row into an item. Returns 1 when it really added one. */
static int inp_submit(void) {
    int ok;
    if (inp_len <= 0) return 0;
    ok = add_text(inp);
    inp_len = 0;
    inp[0] = 0;
    return ok;
}

/* 对话框那条路（`ui_dlg_input`）。返回长度，**取消回 -1**。
 * The dialog path (`ui_dlg_input`). It returns a length and **-1 when cancelled**.
 * ⚠ 用户在手机上用软键盘打字、在桌面上是 `--answer` 脚本喂 —— 两边都走这一个入口。
 * ⚠ Typed on a phone's soft keyboard, or fed by `--answer` on the desktop; both come in here.
 */
static void dlg_add(void) {
    char tmp[INPCAP];
    int n;
    tmp[0] = 0;
    n = ui_dlg_input(lang == 0 ? "新增待办" : "New task",
                     lang == 0 ? "输入内容后回车" : "type it, then Enter",
                     tmp, INPCAP);
    if (n > 0) add_text(tmp);
}

/* 点/按落在哪儿：条目下标；-2 = ＋ 按钮；-1 = 空白。
 * Where a tap landed: the item index, -2 for the + button, -1 for empty space.
 * `*on_del` 回来时 = 1 表示落在右侧的删除区。
 * `*on_del` comes back as 1 when the tap was on the delete zone.
 * ⚠ 与 `draw` **共用** `row_rect` / `del_rect` / `add_rect`（文件头 ④）。
 * ⚠ It **shares** `row_rect` / `del_rect` / `add_rect` with `draw` (pitfall 4).
 */
static int hit(int px, int py, int *on_del) {
    int x;
    int y;
    int w;
    int h;
    int k;
    *on_del = 0;
    add_rect(&x, &y, &w, &h);
    if (px >= x && px < x + w && py >= y && py < y + h) return -2;
    k = 0;
    while (k < t_vis && t_top + k < t_n) {
        row_rect(k, &x, &y, &w, &h);
        if (py >= y && py < y + h) {
            del_rect(k, &x, &y, &w, &h);
            if (px >= x && px < x + w && py >= y && py < y + h) *on_del = 1;
            return t_top + k;
        }
        k = k + 1;
    }
    return -1;
}

/* ── 主流程 ─────────────────────────────────────────────────────────── */
/* -- Main flow -- */
int main(void) {
    int msg[4];
    int t;
    int t0;
    int done;
    int k;
    int i;
    int od;
    int dt;

    lang = ui_scr_get_language();     /* 开局查一次，别每帧调 */
    /* queried once, not every frame */
    press_i = -1;
    inp_len = 0;
    inp[0] = 0;

    /* 开窗传 `0, 0`（= 尺寸交给宿主），**开窗之后**再取一次尺寸 —— 那一次拿到的才是**真实画布**，
     * 窗口与画布才是一套坐标系（开窗前的 `layout()` 只是先算一版）。竖屏 + 不要手柄区。
     * Open with `0, 0` (let the host size it) and take the size **again after** the open -- that pass is the
     * **real canvas**, so window and canvas share one coordinate space (the pre-open `layout()` is just a first pass).
     * Portrait, no gamepad. */
    layout();
    ui_win_open_ex("todo.c", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    layout();

    load_all();
    print_str("[todo] ");
    print_int(t_n);
    print_str(lang == 0 ? " 条待办（来自 ui_store_*）\n" : " items loaded from ui_store_*\n");

    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;   /* 空闲超时（验收用） */
            /* idle timeout */
            continue;
        }
        t0 = ui_time_get_ms();

        if (t == VML_MSG_KEYDOWN) {
            k = msg[1];
            /* ⚠ **特殊键一律排在"可打印 ASCII"之前**（见文件头 ③）：方向键 37..40 落在 32..126 里，
             * ⚠ **Special keys come before the printable range** (pitfall 3): the arrows are 37..40,
             *   先判可打印就会把一次方向键变成输入行里的一个 `%`。
             *   which are inside 32..126, so testing printable first types a literal `%`. */
            if (k == VML_KEY_ESCAPE || k == 'q' || k == 'Q') {
                done = 1;
            } else if (k == VK_TAB) {
                dlg_add();
                save_all();
                draw();
            } else if (k == VML_KEY_BACKSPACE) {
                inp_back();
                draw();
            } else if (k == VML_KEY_ENTER) {
                if (inp_submit() != 0) save_all();
                draw();
            } else if (k == VML_KEY_UP) {
                t_top = t_top - 1;
                clamp_top();
                draw();
            } else if (k == VML_KEY_DOWN) {
                t_top = t_top + 1;
                clamp_top();
                draw();
            } else if (k >= 32 && k <= 126) {
                inp_push(k);
                draw();
            }
            continue;
        }

        if (t == VML_MSG_TOUCHDOWN || t == VML_MSG_MOUSEDOWN) {
            i = hit(msg[1], msg[2], &od);
            if (i == -2) {
                dlg_add();                 /* 点 ＋ = 弹输入框 */
                /* the + opens the dialog */
                save_all();
                draw();
            } else if (i >= 0) {
                press_i = i;
                press_del = od;
                press_ms = ui_time_get_ms();
            }
            continue;
        }

        if (t == VML_MSG_TOUCHUP || t == VML_MSG_MOUSEUP) {
            i = press_i;
            od = press_del;
            dt = ui_time_get_ms() - press_ms;
            press_i = -1;
            if (i >= 0 && i < t_n) {
                /* 删除区一点就删；正文区**长按**才算删，短按是切换完成 */
                /* The delete zone deletes on a tap; the body needs a **long press** to delete, a short
                 * one toggles done. */
                if (od != 0 || dt > LONG_PRESS_MS) {
                    del_item(i);
                } else {
                    t_done[i] = t_done[i] ? 0 : 1;
                }
                save_all();
                draw();
            }
            continue;
        }

        if (t == VML_MSG_WINDOWORIENT || t == VML_MSG_WINDOWRESIZE) {
            layout();
            draw();
            continue;
        }
        if (t == VML_MSG_WINDOWCLOSE) done = 1;
    }

    /* 收尾只有一处：`Esc`/`q`、空闲超时、窗口返回键，三条路都从这里出去。
     * One single exit: `Esc`/`q`, the idle timeout and the window's back arrow all leave here. */
    save_all();
    print_str("[todo] ");
    print_int(t_n);
    print_str(lang == 0 ? " 条已存回 ui_store_*，退出\n" : " items saved back to ui_store_*, quitting\n");
    return 0;
}
