c/app/todo.c · 33600 字节 · 下载原文
/* 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;
}