← 都来码 / Dolaima← 例程索引c/app/stopwatch.c原文

c/app/stopwatch.c · 30232 字节 · 下载原文

/* stopwatch.c —— 常用小程序⑥:**秒表 / 倒计时**(第 4 层:`ui_*` 图形界面)
 * stopwatch.c -- Everyday utility #6: a **stopwatch / countdown** (layer 4: the `ui_*` graphical UI)
 *
 * 干什么:一扇窗口两种模式,顶部两个页签切换。
 * What it does: one window, two modes, switched by the two tabs at the top.
 * · **秒表**:开始 / 暂停 / 清零,还可以**记圈**(下面列出每一圈的时间)。
 * · **Stopwatch**: start / pause / reset, plus **laps** (each lap's time is listed below).
 * · **倒计时**:1 / 3 / 5 分钟三个预设,也能 −10 秒 / +10 秒自己调;到点会停住并弹一行提示。
 * · **Countdown**: three presets (1 / 3 / 5 minutes) plus -10 s / +10 s; at zero it stops and says so.
 * 大字显示 `分:秒.百分秒`,自己格式化 —— `ui_2d_text` **只吃字符串**,第 4 层没有 `print_int`。
 * The big readout is `MM:SS.hh`, formatted by hand -- `ui_2d_text` takes **strings only**, and layer 4
 * has no `print_int`.
 *
 * 操作(触摸与键盘两条路都在)
 * Controls (touch and keyboard both work)
 *
 * · 触摸:顶上两个页签切模式;中间那根大按钮 = 开始 / 暂停;下面一排 = 记圈 / 清零 / 改时长。
 * · Touch: the two tabs switch modes; the big button starts and pauses; the row below does lap, reset
 *   and the duration.
 * · 键盘:`空格` 开始 / 暂停 · `M` 切模式 · `L` 记圈 · `R` 清零 · `←`/`→` 各 ±10 秒 · `Esc`/`q` 退出。
 * · Keyboard: Space starts/pauses, `M` switches mode, `L` laps, `R` resets, Left/Right shift ten
 *   seconds, and `Esc`/`q` quits.
 *
 * 时间怎么算 —— **`ui_time_get_ms()` 的差值,绝不"每帧加一个固定值"**
 * How the time is computed -- **a difference of `ui_time_get_ms()`**, never a fixed step per frame
 *
 * 本仓的规矩(E162):**帧率不是时间**。宿主一改帧率,"每帧加 16 毫秒"这类计时立刻就不准,
 * The repo rule (E162): **the frame rate is not time**. Change the host's pacing and a "add 16 ms per
 * 而屏幕上看不出任何异常 —— 只有对着真秒表比才看得出来。
 * frame" clock drifts immediately, with nothing visibly wrong on screen.
 * 所以这里只保存两个数:已经累计的毫秒 `acc`、本次启动的时刻 `t0`。
 * So only two numbers are kept here: the milliseconds accumulated so far (`acc`) and when the current
 * run started (`t0`).
 * 任何时刻的读数都是 `acc + (现在 - t0)` —— 暂停就是把 `acc` 定住、把 `t0` 丢掉。
 * Any reading is `acc + (now - t0)`; pausing just freezes `acc` and drops `t0`.
 *
 * 到点的提示(**桌面无效就无效,绝不显示"失败"**)
 * The alert at zero (**harmless when the desktop ignores it, and never shown as a failure**)
 *
 * `ui_tone_on` 起一个音、`ui_vibrate_simple` 震一下。桌面宿主上没有声卡和马达 ——
 * `ui_tone_on` starts a tone and `ui_vibrate_simple` buzzes. The desktop host has no sound card and no
 * 实测指令行宿主里 `tone_on` 回 -1、`vibrate` 回 0;手机上是好的。
 * motor: measured in the command-line host, `tone_on` returns -1 and `vibrate` returns 0. On a phone
 * **所以这两个返回值一律丢掉**(写进变量会诱使人去判它、进而把结果画到界面上,
 * both work. So **both return values are dropped on the floor**; keeping them would tempt someone into
 * 而桌面端那行字看着就像"坏了")。调用本身不影响任何正确性。
 * testing them and painting the outcome on screen, which would make the desktop look broken.
 *
 * 踩过的坑(实测)
 * Pitfalls (measured)
 *
 * ① `VML_KEY_LEFT/RIGHT` = 37/39、`VML_KEY_BACKSPACE` = 8 —— 都在可打印区间附近,
 * ① `VML_KEY_LEFT/RIGHT` are 37/39 and `VML_KEY_BACKSPACE` is 8 -- all near the printable range, so
 *   判按键时**具名键必须排在"可打印字符"之前**。
 *   named keys **must come before the printable range**.
 * ② `ui_2d_text` 的 `y` 是**基线**(墨迹在 y 的**上方**)。按"y 是盒顶"排,
 * ② The `y` of `ui_2d_text` is the **baseline** (ink sits **above** y). Lay out as if y were the top
 *   大字会整体下沉,下沿被按钮压住。居中放一行字的算式是 `y_底 = 盒中心 + 字高 × 0.35`。
 *   and the big digits sink until the buttons cover their lower edge; the centring formula is
 *   `baseline = box_center + size * 0.35`.
 * ③ **画与命中判定共用同一套几何**(`tab_rect` / `btn_rect`)。各算一套就会"看着点的是暂停、
 * ③ **Drawing and hit testing share one set of geometry** (`tab_rect` / `btn_rect`); two copies and
 *   实际按到了清零"。
 *   the pause you aimed at turns out to be the reset you pressed.
 * ④ `ui_2d_text` 只收**字符串**:`分:秒.百分秒` 得自己拼(**逐字节写数字**,
 * ④ `ui_2d_text` takes **strings**: `MM:SS.hh` has to be built by hand. Writing the digits a byte at a
 *   一位一个字符没问题 —— 危险的是**往缓冲里逐字节写汉字**,一个汉字 3 个字节)。
 *   time is fine; what is dangerous is writing a CJK glyph byte by byte, since one glyph is 3 bytes.
 * ⑤ 空闲 10 秒也退出(例程要能被自动验收)。**跑的时候也不重置这个计时** ——
 * ⑤ Ten idle seconds also quit (samples must be verifiable automatically). A running clock does **not**
 *   否则"正在计时"的程序永远不退出,自动验收就得靠 `--timeout` 硬砍。
 *   reset that clock; otherwise a running program never ends and the check has to hard-kill it.
 *
 * 跑法(桌面脚手架,秒级,别为改一行去打 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/stopwatch.c \
 *         --vml-home third_party/vml --timeout 40 --frame /tmp/stopwatch.png
 * 手机上:命令行页敲 `vml run examples/c/app/stopwatch.c`。
 * On the phone: type `vml run examples/c/app/stopwatch.c` in the command-line page.
 */

#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

/* 记圈最多留几条 / 一次 ±10 秒 / 倒计时的上下限 */
/* How many laps are kept, the ten-second step, and the countdown's floor and ceiling. */
#define MAXLAP       6
#define STEP_MS      10000
#define CD_MIN       10000
#define CD_MAX       5990000

/* 预设的三个时长(1 / 3 / 5 分钟)*/
/* The three presets: one, three and five minutes. */
#define CD_1M 60000
#define CD_3M 180000
#define CD_5M 300000

/* 配色(真彩 AARRGGBB,第 4 层没有索引色)*/
/* Colours (true colour AARRGGBB; layer 4 has no palette). */
#define C_BG     0xFF0E1116
#define C_CARD   0xFF1A1F28
#define C_BTN    0xFF2A3340
#define C_TABON  0xFF4A90D9
#define C_TITLE  0xFFE8E8F0
#define C_DIM    0xFF9AA0B0
#define C_RUN    0xFF3FAE6A
#define C_PAUSE  0xFFFFC64A
#define C_ALARM  0xFFE05A4A
#define C_INK    0xFF101018
/* 后四个是状态色:计时中 = 绿、暂停 = 琥珀、到点 = 红,`C_INK` 是亮底按钮上的深色字 */
/* The last four are state colours: running green, paused amber, finished red; `C_INK` is dark ink on a bright button. */

/* ── 状态 ───────────────────────────────────────────────────────────── */
/* -- 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 big_fs;
static int tab_y, tab_h;
static int big_y;
static int status_y;
static int lap_top;
static int pre_y, pre_h;
static int main_y, main_h;
static int aux_y, aux_h;
static int hint_y;

/* `fs` 是小字、`big_fs` 是大字读数;下面四组 y 是四排按钮的位置 */
/* `fs` is the small font and `big_fs` the big readout; the four y values place the four button rows. */
static int mode;
static int running;
static int acc;
static int t0;
static int limit;
static int fired;
static int lap_ms[MAXLAP];
static int lap_n;

/* `acc` + `(现在 - t0)` 才是读数(见文件头"时间怎么算");`fired` = 倒计时已经到过点 */
/* The reading is `acc + (now - t0)` (see the header); `fired` means the countdown already hit zero. */

/* ── 小工具 ─────────────────────────────────────────────────────────── */
/* -- Small helpers -- */

/* 整数 → 十进制串。返回**同一个静态缓冲**,别在一个表达式里连用两次。
 * 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;
}

/* 毫秒 → `MM:SS.hh` 写进 `d`(要 9 个字节)。
 * Milliseconds to `MM:SS.hh`, written into `d` (nine bytes needed).
 * ⚠ **写字进缓冲,不返回静态缓冲** —— 静态的那份一被复用,同一次绘制里两处读数就会串。
 * ⚠ It **writes into a buffer instead of returning a static one**: one shared static would make two
 *   readouts in the same frame overwrite each other.
 * 封顶 99:59.99(再大显示就不下了)
 * capped at 99:59.99, beyond which it no longer fits.
 */
static void fmt_into(char *d, int ms) {
    int mm;
    int ss;
    int hh;
    if (ms < 0) ms = 0;
    if (ms > 5999990) ms = 5999990;
    mm = ms / 60000;
    ss = (ms / 1000) % 60;
    hh = (ms / 10) % 100;
    d[0] = (char)('0' + (mm / 10) % 10);
    d[1] = (char)('0' + mm % 10);
    d[2] = ':';
    d[3] = (char)('0' + ss / 10);
    d[4] = (char)('0' + ss % 10);
    d[5] = '.';
    d[6] = (char)('0' + hh / 10);
    d[7] = (char)('0' + hh % 10);
    d[8] = 0;
}

/* 已经过了多少毫秒(秒表算的就是它,倒计时算的是"上限减它")
 * How many milliseconds have elapsed (what the stopwatch shows, and what the countdown subtracts). */
static int elapsed(void) {
    int v = acc;
    if (running) v = v + (ui_time_get_ms() - t0);
    return v;
}

/* 屏幕上该显示的那个数 */
/* The number the screen should show */
static int reading(void) {
    int e = elapsed();
    if (mode == 0) return e;
    if (e > limit) e = limit;
    return limit - e;
}

/* ── 提醒 ───────────────────────────────────────────────────────────── */
/* -- The alert -- */

/* 到点的提醒:**试一下**发声与震动,结果**一律丢掉**(见文件头"到点的提示"那一段)。
 * The alert at zero: **try** a tone and a vibration and **drop both results** (see the header).
 * ⚠ 不要写成 `if (ui_tone_on(...) == 0) ...` —— 那会把"桌面没有声卡"变成界面上的一句话。
 * ⚠ Never write `if (ui_tone_on(...) == 0) ...`: that turns "no sound card here" into on-screen text.
 */
static void chime(void) {
    ui_tone_on(0, 84, 110);
    ui_vibrate_simple(300, 200);
}

/* ── 动作 ───────────────────────────────────────────────────────────── */
/* -- Actions -- */

static void reset_all(void) {
    acc = 0;
    running = 0;
    fired = 0;
    lap_n = 0;
}

static void set_mode(int m) {
    if (m == mode) return;
    mode = m;
    reset_all();
    print_str(m == 0 ? "[stopwatch] mode=stopwatch\n" : "[stopwatch] mode=countdown\n");
}

static void start_stop(void) {
    if (running) {
        acc = elapsed();               /* 定住累计值,丢掉起点 */
        /* freeze the total and drop the origin */
        running = 0;
        print_str("[stopwatch] pause at ");
        print_int(acc);
        print_str(" ms\n");
        return;
    }
    if (mode == 1 && fired) reset_all();   /* 到点之后再按 = 重来 */
    /* pressing again after zero restarts */
    if (mode == 1 && acc >= limit) reset_all();
    running = 1;
    t0 = ui_time_get_ms();
    print_str("[stopwatch] start ");
    print_int(mode);
    print_str(" at ");
    print_int(t0);
    print_str(" ms\n");
}

static void lap(void) {
    int i;
    if (mode != 0) return;
    i = elapsed();
    if (i <= 0) return;
    if (lap_n < MAXLAP) {
        lap_ms[lap_n] = i;
        lap_n = lap_n + 1;
    } else {
        /* 满了就把最老的一圈挤掉(新的一圈才是用户想看的)*/
        /* When full the oldest lap is pushed out (the new one is what the user asked for) */
        i = 0;
        while (i < MAXLAP - 1) { lap_ms[i] = lap_ms[i + 1]; i = i + 1; }
        lap_ms[MAXLAP - 1] = elapsed();
    }
    print_str("[stopwatch] lap ");
    print_int(lap_n);
    print_str(" = ");
    print_int(elapsed());
    print_str(" ms\n");
}

static void adjust(int d) {
    if (mode != 1 || running) return;
    limit = limit + d;
    if (limit < CD_MIN) limit = CD_MIN;
    if (limit > CD_MAX) limit = CD_MAX;
    acc = 0;
    fired = 0;
    print_str("[stopwatch] limit = ");
    print_int(limit);
    print_str(" ms\n");
}

static void preset(int v) {
    if (mode != 1) return;
    limit = v;
    reset_all();
    print_str("[stopwatch] preset = ");
    print_int(v);
    print_str(" ms\n");
}

/* 每次画之前推一次:倒计时到点就停住、提醒一声(**只做一次**,靠 `running` 归零兜住)。
 * Advanced once before every draw: the countdown stops at zero and buzzes (only once, since `running`
 * going to zero is what gates it).
 * ⚠ 判据是"已经过了多少 ≥ 上限",不是"这一帧减一减到 0" —— 帧率一变,后者就不准了。
 * ⚠ The test is "elapsed >= limit", not "this frame's step crossed zero" -- the latter breaks the
 *   moment the frame rate changes.
 */
static void tick(void) {
    if (mode == 1 && running != 0 && elapsed() >= limit) {
        acc = limit;
        running = 0;
        fired = 1;
        chime();
        print_str("[stopwatch] countdown reached zero\n");
    }
}

/* 当前该显示的状态一行字 */
/* The one-line status the screen should show */
static char *status_text(void) {
    if (mode == 0) {
        if (running) return lang == 0 ? "秒表 · 计时中" : "stopwatch - running";
        if (elapsed() > 0) return lang == 0 ? "秒表 · 已暂停" : "stopwatch - paused";
        return lang == 0 ? "秒表 · 按开始" : "stopwatch - press start";
    }
    if (fired) return lang == 0 ? "时间到!" : "time is up!";
    if (running) return lang == 0 ? "倒计时 · 计时中" : "countdown - running";
    if (elapsed() > 0) return lang == 0 ? "倒计时 · 已暂停" : "countdown - paused";
    return lang == 0 ? "倒计时 · 按开始" : "countdown - press start";
}

/* ── 版式与按钮几何(画与命中判定**只有一个真源**)──────────────────── */
/* -- Layout and button geometry (**one single source** for drawing and hit testing) -- */

/* 顶部第 i 个页签(共 2 个) */
/* The i-th tab at the top (there are two) */
static void tab_rect(int i, int *rx, int *ry, int *rw, int *rh) {
    int w = (SW - pad * 2 - 8) / 2;
    *rx = pad + i * (w + 8);
    *ry = tab_y;
    *rw = w;
    *rh = tab_h;
}

/* 某一排里第 i 个按钮(共 n 个) */
/* The i-th button of `n` in one row */
static void btn_rect(int i, int n, int row_y, int h, int *rx, int *ry, int *rw, int *rh) {
    int gap = 8;
    int w = (SW - pad * 2 - gap * (n - 1)) / n;
    *rx = pad + i * (w + gap);
    *ry = row_y;
    *rw = w;
    *rh = h;
}

static void layout(void) {
    int avail;
    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 / 30;
    if (fs < 12) fs = 12;
    if (fs > 18) fs = 18;

    avail = SW - pad * 2;
    big_fs = avail / 5;         /* 8 个字正好占满一行 */
    /* eight digits fill one line exactly */
    if (big_fs < 26) big_fs = 26;
    if (big_fs > 64) big_fs = 64;

    tab_y = pad;
    tab_h = fs + 18;
    big_y = tab_y + tab_h + 28 + big_fs;
    status_y = big_y + fs + 18;
    lap_top = status_y + 10;

    pre_y = SH - pad - 200;
    pre_h = fs + 20;
    main_y = SH - pad - 148;
    main_h = 52;
    aux_y = SH - pad - 88;
    aux_h = fs + 24;
    hint_y = SH - pad - 18;
}

static void draw(void) {
    int i;
    int x;
    int y;
    int w;
    int h;
    int col;
    int n;
    int row_y;
    int row_hh;
    char tb[64];
    char tb2[16];

    tick();                     /* ⚠ 先推进状态再画(倒计时到点就在这一句里定格)*/
                                /* ⚠ Advance the state before drawing (this is where the countdown stops) */
    ui_2d_clear(C_BG);

    /* 顶部两个页签:选中的那颗是蓝底深字
     * The two tabs at the top: the selected one is a blue box with dark ink */
    for (i = 0; i < 2; i++) {
        tab_rect(i, &x, &y, &w, &h);
        ui_2d_rect(x, y, w, h, mode == i ? C_TABON : C_CARD, 1, 0, 10);
        ui_2d_text(x + w / 2, y + h / 2 + (fs * 35) / 100,
                   i == 0 ? (lang == 0 ? "秒表" : "Stopwatch")
                          : (lang == 0 ? "倒计时" : "Countdown"),
                   mode == i ? C_INK : C_DIM, fs, VML_ANCHOR_CENTER);
    }

    /* 大字读数:这一行就是整个程序的主角,颜色说明状态(绿=跑 / 琥珀=停 / 红=到点)
     * The big readout, the star of the show: the colour carries the state (green running, amber
     * paused, red finished) */
    col = C_PAUSE;
    if (running) col = C_RUN;
    if (mode == 1 && fired) col = C_ALARM;
    fmt_into(tb2, reading());
    ui_2d_text_styled(SW / 2, big_y, tb2, col, big_fs, VML_ANCHOR_CENTER, VML_FONT_BOLD);

    ui_2d_text(SW / 2, status_y, status_text(), C_DIM, fs, VML_ANCHOR_CENTER);

    /* 秒表:把记下的圈列出来;倒计时:显示这次的上限,并说明怎么改
     * Stopwatch: list the laps. Countdown: show the limit and how to change it. */
    if (mode == 0) {
        row_y = lap_top;
        row_hh = fs + 14;
        i = 0;
        while (i < lap_n && row_y + row_hh < main_y - 6) {
            tb[0] = 0;
            strcat(tb, "L");
            strcat(tb, numstr(i + 1));
            strcat(tb, "    ");
            fmt_into(tb2, lap_ms[i]);
            strcat(tb, tb2);
            ui_2d_text(SW / 2, row_y + fs, tb, C_DIM, fs, VML_ANCHOR_CENTER);
            row_y = row_y + row_hh;
            i = i + 1;
        }
        if (lap_n == 0) {
            ui_2d_text(SW / 2, lap_top + fs,
                       lang == 0 ? "计时中按 记圈 会记下这一圈" : "press Lap while running to record a lap",
                       C_DIM, fs - 2, VML_ANCHOR_CENTER);
        }
    } else {
        tb[0] = 0;
        strcat(tb, lang == 0 ? "上限 " : "limit ");
        fmt_into(tb2, limit);
        strcat(tb, tb2);
        ui_2d_text(SW / 2, lap_top + fs, tb, C_DIM, fs, VML_ANCHOR_CENTER);
        ui_2d_text(SW / 2, lap_top + fs + fs + 10,
                   lang == 0 ? "暂停时点一下可以 ±10 秒" : "while paused a tap shifts ten seconds",
                   C_DIM, fs - 2, VML_ANCHOR_CENTER);
    }

    /* 倒计时那一排预设(秒表模式下不画,也不参与命中判定)
     * The preset row (countdown only; it is neither drawn nor hit-tested in stopwatch mode) */
    if (mode == 1) {
        i = 0;
        while (i < 3) {
            btn_rect(i, 3, pre_y, pre_h, &x, &y, &w, &h);
            ui_2d_rect(x, y, w, h, C_BTN, 1, 0, 8);
            ui_2d_text(x + w / 2, y + h / 2 + (fs * 35) / 100,
                       i == 0 ? (lang == 0 ? "1 分" : "1 min")
                              : (i == 1 ? (lang == 0 ? "3 分" : "3 min")
                                        : (lang == 0 ? "5 分" : "5 min")),
                       C_TITLE, fs, VML_ANCHOR_CENTER);
            i = i + 1;
        }
    }

    /* 中间那根大按钮:开始 / 暂停 / 重来
     * The big button in the middle: start / pause / again */
    btn_rect(0, 1, main_y, main_h, &x, &y, &w, &h);
    ui_2d_rect(x, y, w, h, running ? C_PAUSE : C_TABON, 1, 0, 12);
    ui_2d_text_styled(x + w / 2, y + h / 2 + (fs * 35) / 100,
                      running ? (lang == 0 ? "暂停" : "Pause")
                              : (fired ? (lang == 0 ? "重来" : "Again")
                                       : (lang == 0 ? "开始" : "Start")),
                      C_INK, fs + 4, VML_ANCHOR_CENTER, VML_FONT_BOLD);

    /* 下面一排:秒表 = 记圈 / 清零;倒计时 = −10 秒 / +10 秒 / 清零
     * The row below: stopwatch gets lap and reset; the countdown gets -10 s, +10 s and reset */
    if (mode == 0) {
        n = 2;
        btn_rect(0, 2, aux_y, aux_h, &x, &y, &w, &h);
        ui_2d_rect(x, y, w, h, C_BTN, 1, 0, 8);
        ui_2d_text(x + w / 2, y + h / 2 + (fs * 35) / 100, lang == 0 ? "记圈" : "Lap",
                   C_TITLE, fs, VML_ANCHOR_CENTER);
        btn_rect(1, 2, aux_y, aux_h, &x, &y, &w, &h);
        ui_2d_rect(x, y, w, h, C_BTN, 1, 0, 8);
        ui_2d_text(x + w / 2, y + h / 2 + (fs * 35) / 100, lang == 0 ? "清零" : "Reset",
                   C_TITLE, fs, VML_ANCHOR_CENTER);
    } else {
        btn_rect(0, 3, aux_y, aux_h, &x, &y, &w, &h);
        ui_2d_rect(x, y, w, h, C_BTN, 1, 0, 8);
        ui_2d_text(x + w / 2, y + h / 2 + (fs * 35) / 100, "-10s", C_TITLE, fs, VML_ANCHOR_CENTER);
        btn_rect(1, 3, aux_y, aux_h, &x, &y, &w, &h);
        ui_2d_rect(x, y, w, h, C_BTN, 1, 0, 8);
        ui_2d_text(x + w / 2, y + h / 2 + (fs * 35) / 100, "+10s", C_TITLE, fs, VML_ANCHOR_CENTER);
        btn_rect(2, 3, aux_y, aux_h, &x, &y, &w, &h);
        ui_2d_rect(x, y, w, h, C_BTN, 1, 0, 8);
        ui_2d_text(x + w / 2, y + h / 2 + (fs * 35) / 100, lang == 0 ? "清零" : "Reset",
                   C_TITLE, fs, VML_ANCHOR_CENTER);
    }

    ui_2d_text(SW / 2, hint_y,
               lang == 0 ? "空格 开始/暂停 · M 切模式 · L 记圈 · R 清零 · Esc 退出"
                         : "Space start/pause · M mode · L lap · R reset · Esc quit",
               C_DIM, fs - 3, VML_ANCHOR_CENTER);

    ui_2d_present();      /* ⚠ 不调这句 = 黑屏 */
    /* without this call you get a black screen */
}

/* 点/按落在哪儿:见上面那套 `*_rect`。返回 0 = 没落在任何按钮上。
 * Where a tap landed, using the `*_rect` set above. Returns 0 when it hit nothing.
 * 1/2 = 页签,3 = 大按钮,10..12 = 下面那一排,20..22 = 预设那一排。
 * 1/2 are the tabs, 3 the big button, 10..12 the lower row and 20..22 the preset row.
 */
static int hit(int px, int py) {
    int x;
    int y;
    int w;
    int h;
    int i;
    for (i = 0; i < 2; i++) {
        tab_rect(i, &x, &y, &w, &h);
        if (px >= x && px < x + w && py >= y && py < y + h) return 1 + i;
    }
    btn_rect(0, 1, main_y, main_h, &x, &y, &w, &h);
    if (px >= x && px < x + w && py >= y && py < y + h) return 3;
    if (mode == 1) {
        for (i = 0; i < 3; i++) {
            btn_rect(i, 3, pre_y, pre_h, &x, &y, &w, &h);
            if (px >= x && px < x + w && py >= y && py < y + h) return 20 + i;
        }
        for (i = 0; i < 3; i++) {
            btn_rect(i, 3, aux_y, aux_h, &x, &y, &w, &h);
            if (px >= x && px < x + w && py >= y && py < y + h) return 10 + i;
        }
        return 0;
    }
    for (i = 0; i < 2; i++) {
        btn_rect(i, 2, aux_y, aux_h, &x, &y, &w, &h);
        if (px >= x && px < x + w && py >= y && py < y + h) return 10 + i;
    }
    return 0;
}

/* 按钮编号 → 动作(触摸与键盘**走同一张表**,别一边改一边忘)
 * Button id to action (touch and keyboard go through **the same table**, so one cannot drift) */
static void act(int id) {
    if (id == 1) { set_mode(0); return; }
    if (id == 2) { set_mode(1); return; }
    if (id == 3) { start_stop(); return; }
    if (mode == 0) {
        if (id == 10) { lap(); return; }
        if (id == 11) { reset_all(); print_str("[stopwatch] reset\n"); return; }
        return;
    }
    if (id == 10) { adjust(0 - STEP_MS); return; }
    if (id == 11) { adjust(STEP_MS); return; }
    if (id == 12) { reset_all(); print_str("[stopwatch] reset\n"); return; }
    if (id == 20) { preset(CD_1M); return; }
    if (id == 21) { preset(CD_3M); return; }
    if (id == 22) { preset(CD_5M); return; }
}

/* ── 主流程 ─────────────────────────────────────────────────────────── */
/* -- Main flow -- */
int main(void) {
    int msg[4];
    int t;
    int t0idle;
    int done;
    int k;
    int wait_ms;
    int id;

    lang = ui_scr_get_language();     /* 开局查一次,别每帧调 */
    /* queried once, not every frame */
    mode = 0;
    limit = CD_3M;                    /* 倒计时的出厂值 = 3 分钟 */
    /* the countdown's default: three minutes */
    reset_all();

    /* 开窗传 `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("stopwatch.c", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    layout();

    print_str("[stopwatch] ready (mode=stopwatch, limit=180000 ms)\n");
    draw();

    t0idle = ui_time_get_ms();
    done = 0;
    while (!done && ui_win_is_closed() == 0) {
        /* 跑的时候把等待缩短,读数才跟得上(百分秒每 40 毫秒动一次)。
         * Shorten the wait while running so hundredths keep up (they move every 40 ms).
         * ⚠ 空闲计时**不因为"在跑"而重置** —— 否则这个程序永远不会自己退出(文件头 ⑤)。
         * ⚠ The idle clock is **not** reset by "it is running", or the program would never end (pitfall 5). */
        wait_ms = running ? 40 : 200;
        t = ui_msg_wait(msg, wait_ms);
        if (t == VML_MSG_NONE) {
            if (running) { draw(); continue; }              /* 在跑就重画这一帧 */
            /* repaint while running */
            if (ui_time_get_ms() - t0idle > IDLE_EXIT_MS) done = 1;   /* 空闲超时 */
            /* idle timeout */
            continue;
        }
        t0idle = ui_time_get_ms();

        if (t == VML_MSG_KEYDOWN) {
            k = msg[1];
            /* ⚠ 具名键**排在可打印字符之前**(文件头 ①):37/39 之类都紧挨着 32..126。
             * ⚠ Named keys **come before printable characters** (pitfall 1): 37/39 sit right beside 32..126. */
            if (k == VML_KEY_ESCAPE || k == 'q' || k == 'Q') {
                done = 1;
            } else if (k == VML_KEY_SPACE) {
                act(3);
                draw();
            } else if (k == 'm' || k == 'M') {
                set_mode(mode == 0 ? 1 : 0);
                draw();
            } else if (k == 'r' || k == 'R') {
                reset_all();
                print_str("[stopwatch] reset\n");
                draw();
            } else if (k == 'l' || k == 'L') {
                lap();
                draw();
            } else if (k == '1' && mode == 1) {
                preset(CD_1M);
                draw();
            } else if (k == '3' && mode == 1) {
                preset(CD_3M);
                draw();
            } else if (k == '5' && mode == 1) {
                preset(CD_5M);
                draw();
            } else if (k == VML_KEY_LEFT || k == '-') {
                adjust(0 - STEP_MS);
                draw();
            } else if (k == VML_KEY_RIGHT || k == '+' || k == '=') {
                adjust(STEP_MS);
                draw();
            }
            continue;
        }

        if (t == VML_MSG_TOUCHDOWN || t == VML_MSG_MOUSEDOWN) {
            id = hit(msg[1], msg[2]);
            if (id != 0) {
                act(id);
                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. */
    print_str("[stopwatch] exit, reading ");
    print_int(reading());
    print_str(" ms, laps ");
    print_int(lap_n);
    print_str("\n");
    return 0;
}