diff --git a/patch_v2/files_app/README.md b/patch_v2/files_app/README.md new file mode 100644 index 0000000..e83ecf0 --- /dev/null +++ b/patch_v2/files_app/README.md @@ -0,0 +1,87 @@ +# os.meow.files — MeowOS 外置文件管理器 + +TF 卡应用(`framework=1` 走 `meow_ui` SDK)。浏览 TF 卡目录树,支持进/退目录、列表虚拟化、 +行操作(删除/重命名/新建目录/属性)、文本预览(≤256KB 截断)、空态/错误页;写操作严格限制在 +TF 卡内(`/usr/data/mnt/sd_0`),拒绝 `..` 越界与卡外路径。 + +## 目录结构(多文件,批次②拆分范本) + +``` +patch_v2/files_app/ +├── app.c # main + 装配 + 事件分发(key/tap/swipe/tick/touch → 当前页) +├── files_internal.h # 跨 TU 共享头(Activity id / 布局 / chrome/browser/viewer 声明) +├── build_files.sh # 交叉编译脚本(zig-mipsel-cc 静态链 meow_ui.a libz.a -lpthread) +├── app.json # {"name":"Files","exec":".../os.meow.files/files",...,"framework":1,"immersive":1} +├── README.md +├── ui/ +│ ├── browser.c # 目录浏览页(ACT_BROWSER 根页):虚拟化列表 + 空态 + 行操作 +│ ├── viewer.c # 文本预览页(ACT_VIEWER 子页):只读、可滚动、≤256KB 截断 +│ └── common/ +│ └── chrome.c # 共享 chrome:topbar 建置 / 内容区几何 / 空态&页脚绘制 +├── logic/ +│ ├── fsx.h # 纯业务接口(不依赖 meow_ui) +│ └── fsx.c # 路径安全/格式化/枚举排序/虚拟化区间/写操作(可宿主测) +└── test/ + └── test_fsx.c # 宿主单元自测(gcc 直接编,指向临时卡根) +``` + +## 构建 + +```bash +cd patch_v2/files_app && ./build_files.sh +# 产物:files(MIPS32r2 静态 ELF,~270KB)+ ./files + app.json +``` + +构建脚本仿 `hello_app/build_hello.sh`:`zig-mipsel-cc -Os -static -Wall -Wextra -I../sdk -I.` +链 `../sdk/meow_ui.a "$SYSROOT/lib/libz.a" -lpthread`。**零触及 `patch_v2/sdk/`**(未新增/改任何 SDK 文件); +`-Wall -Wextra` 0 warning。 + +## 部署 + +``` +/usr/data/mnt/sd_0/MeowAPPs/os.meow.files/ +├── app.json +└── files +``` + +数据目录 `MeowData/os.meow.files/{data,cache}` 由 launcher 经 `MEOW_DATA_DIR`/`MEOW_CACHE_DIR` 注入; +v1 无需持久化,暂未使用(启动恒进卡根 `/usr/data/mnt/sd_0/`)。 + +## 宿主自测(不跨编/不刷机) + +```bash +cd patch_v2/files_app +gcc -O2 -Wall -Wextra -DFSX_SD_ROOT='"/tmp/meowfsx_fsx_test"' -Ilogic \ + test/test_fsx.c logic/fsx.c -o /tmp/test_fsx && /tmp/test_fsx +``` + +覆盖:路径安全(`within_sd`/`join`/`sanitize`/`dirname`/`basename`)、展示格式化(`fmt_size`/`fmt_time`)、 +目录枚举排序(`scan_dir`/`sort`)、虚拟化区间(`visible_range`/`max_scroll`)、写操作(`mkdir`/`rename`/ +`delete`,含卡外/越界拒绝)、非文本写拒绝。宿主全 PASS。 + +## 交互 + +- 触摸:行 body=进目录/开文本预览;右侧 `⋯` 或长按→BottomSheet(删除[确认弹窗]/重命名[IME]/新建目录[IME]/属性[大小·mtime]); + TopAppBar `NEW`=当前目录新建。 +- 左缘左滑/返回钮:预览页→回目录;浏览器→上级目录;卡根再左滑=退出 App。 +- 物理键不映射(APPS.md §3.4.1):息屏/音量/媒体语义归系统/SDK。 + +## 已知限制 / SDK 能力缺口(未改 SDK,如需请走 agent 立项) + +1. **SDK `meow_lazycolumn` 仅渲染单行 label**(`meow_list_source` 填文本),无法表达「图标+名称+大小/时间+右侧操作钮」 + 的行布局。本 App 用 `meow_widget_custom_create` 自绘虚拟化列表满足该行版式;`fsx_scan_dir` 仍以有界数组缓存 + (`FSX_MAX_ENTRIES=1024`)承载数据,列表虚拟化只画可见行(`fsx_visible_range`)——大目录不一次全读进内存。 +2. **中文字形**:SDK 默认 `common` 字库(≈4k)不含 CJK 全量;exFAT 中文文件名/中文文本预览在 common 档会显示缺字豆腐。 + 预览页不声明 `fonts`(app.json 未加 `"fonts":"full"`/`"gbk"`),如需中文预览可在 app.json 声明大字号档。 +3. **预览仅文本**:按扩展名白名单 + 首 512 字节 NUL 嗅探判文本;二进制/图片无预览(提示「not a text file」)。 + 超 256KB 只读前 256KB,页脚标注 `>256KB (truncated)`。 +4. **符号链接不追链**:v1 视 symlink 为普通文件展示/操作(lstat),避免链出卡外。 +5. **单目录条目上限**:>1024 项时只展示前 1024 并在页脚标注 `showing first 1024`(分页流式读目录为二期)。 + +## 验证状态 + +- 编译:`-Wall -Wextra` 0 warning/0 error,产物 `files`(MIPS32r2 静态 ELF)。 +- 宿主:`test/test_fsx.c` 全 PASS。 +- qemu-mipsel(无 /dev/fb0 → SDK 离屏 480×720):正常运行到事件循环,导出离屏帧, + 确认 init/建页/topbar(返回+NEW)/空态(NO STORAGE CARD)渲染正确、SIGTERM 干净退出。 +- 实机目录列表渲染(有真实 TF 卡目录)需刷机/设备挂载验证——宿主模拟无法造出 `/usr/data/mnt/sd_0`。 diff --git a/patch_v2/files_app/app.c b/patch_v2/files_app/app.c new file mode 100644 index 0000000..c5c8c17 --- /dev/null +++ b/patch_v2/files_app/app.c @@ -0,0 +1,106 @@ +/* app.c — files 应用装配层:main + 组装 + 事件分发。 + * + * 职责(对齐 hello_app/app.c):main 只做初始化/建页/跑事件循环/清理;把 meow_ui_run 的平铺输入 + * 回调(key/tap/swipe/tick/touch)路由到当前活动页(browser/viewer)。页面切换统一走 + * meow_view_page_push/pop(活动栈 + on_enter/on_leave 唯一驱动)。 + * + * 铁律(APPS.md §3.4.1 / WORK_STATE.md):物理键禁映射——不处理任何 key 语义;返回=左缘左滑 + * (on_swipe LEFT)/返回钮/meow_ui_request_exit。目录进退不换页(browser 改 cur_dir 重扫); + * 文本预览换页(push ACT_VIEWER)。退出=在根目录左滑或 SIGTERM(看门狗)。 + */ +#include "files_internal.h" + +#include + +static meow_view *g_browser_view; +static meow_view *g_viewer_view; + +static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); } + +/* ============================================================ */ +/* 事件分发:meow_ui_run 平铺回调 → 当前页 */ +/* ============================================================ */ + +/* 物理键系统语义(息屏/音量/媒体)由 SDK 消化;App 不映射(§3.4.1)。 */ +static void app_on_key(int keycode, int is_down) +{ + (void)keycode; (void)is_down; +} + +static void app_on_tap(int x, int y) +{ + if (viewer_is_active()) { viewer_on_tap(x, y); return; } + if (browser_is_active()) { browser_on_tap(x, y); return; } +} + +static void app_on_swipe(int dir, int dx, int dy) +{ + (void)dx; + if (dir == MEOW_SWIPE_LEFT) { + /* 左缘返回:预览页→回目录;浏览器→上级目录;到根→退出 App。 */ + if (viewer_is_active()) { meow_view_page_pop(); return; } + if (browser_is_active() && browser_go_up()) return; + meow_log(MEOW_LOG_INFO, "exit:swipe"); + meow_ui_request_exit(); + return; + } + if (viewer_is_active()) { viewer_swipe(dir, dy); return; } + if (browser_is_active()) { browser_swipe(dir, dy); return; } +} + +/* 左缘返回手势过程视觉条(SDK 自动画顶部进度条,App 转发)。 */ +static void app_on_swipe_progress(int x_start, int dx) +{ + meow_ui_swipe_hint(x_start + dx); +} + +/* 拖拽过程(跟手):列表/文本内容滚动。 */ +static void app_on_touch_down(int x, int y) +{ + if (viewer_is_active()) viewer_touch_down(x, y); + else if (browser_is_active()) browser_touch_down(x, y); +} +static void app_on_touch_move(int x, int y) +{ + if (viewer_is_active()) viewer_touch_move(x, y); + else if (browser_is_active()) browser_touch_move(x, y); +} + +/* on_tick:长按检测(browser 页)。 */ +static void app_on_tick(void) +{ + if (browser_is_active()) browser_tick(meow_ui_time_ms()); +} + +/* ============================================================ */ +/* main */ +/* ============================================================ */ +int main(void) +{ + meow_log(MEOW_LOG_INFO, "start"); + meow_ui_opts o = { APP_W, APP_H }; + if (meow_ui_init(&o) != 0) { meow_log(MEOW_LOG_ERROR, "init failed"); return 1; } + meow_theme_add_listener(NULL); + + signal(SIGTERM, on_sigterm); + + /* 建两页 view(on_create 建各自控件/状态 + 读卡扫首个目录) */ + g_browser_view = browser_view_create(); + g_viewer_view = viewer_view_create(); + + /* 进入根页(ACT_BROWSER = 目录浏览;默认卡根 /usr/data/mnt/sd_0) */ + meow_view_page_push(ACT_BROWSER); + + meow_ui_events ev = { + app_on_key, app_on_tap, app_on_swipe, app_on_tick, app_on_swipe_progress, + app_on_touch_down, app_on_touch_move, + }; + meow_ui_run(&ev); + + /* 清理:meow_view_free 触发各页 on_destroy(控件 + 文本缓冲) */ + meow_view_free(g_viewer_view); + meow_view_free(g_browser_view); + meow_ui_shutdown(); + meow_log(MEOW_LOG_INFO, "exit"); + return 0; +} diff --git a/patch_v2/files_app/app.json b/patch_v2/files_app/app.json new file mode 100644 index 0000000..8be5303 --- /dev/null +++ b/patch_v2/files_app/app.json @@ -0,0 +1 @@ +{"name":"Files","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.files/files","package":"os.meow.files","version":"0.1","framework":1,"immersive":1,"author":"MeowOS"} diff --git a/patch_v2/files_app/build_files.sh b/patch_v2/files_app/build_files.sh new file mode 100755 index 0000000..16c2645 --- /dev/null +++ b/patch_v2/files_app/build_files.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Build the Files app (MeowOS 外置文件管理器 os.meow.files,SDK framework=1): +# 链 meow_ui.a(已内含 meow_font.o)+ 系统 libz.a + -lpthread,静态 MIPS32r2 (musl sf)。 +# 多文件结构(仿 hello_app 批次②拆分):app.c + ui/browser.c + ui/viewer.c + +# ui/common/chrome.c + logic/fsx.c。仅改本目录文件,编译期不改 SDK(patch_v2/sdk/ 零触及)。 +# mipsel -Wall -Wextra 0 warning。 +set -e +cd "$(dirname "$0")" +ROOT="$HOME/Project/R3ProII" +CC="$ROOT/.tools/zig-mipsel-cc" +SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf" +"$CC" -Os -static -Wall -Wextra -I../sdk -I. \ + app.c ui/browser.c ui/viewer.c ui/common/chrome.c logic/fsx.c \ + ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" -lpthread -o files +ls -la files +file files diff --git a/patch_v2/files_app/files b/patch_v2/files_app/files new file mode 100755 index 0000000..31b903b Binary files /dev/null and b/patch_v2/files_app/files differ diff --git a/patch_v2/files_app/files_internal.h b/patch_v2/files_app/files_internal.h new file mode 100644 index 0000000..c3683de --- /dev/null +++ b/patch_v2/files_app/files_internal.h @@ -0,0 +1,74 @@ +/* files_internal.h — files 应用内部共享头(UI 各页 + app 装配层 + 宿主测试面)。 + * + * 层级(对齐 hello_app 的批次②拆分规范): + * - ui/browser.c 目录浏览页(ACT_BROWSER,根页):列表虚拟化渲染 + 进/退目录 + 行操作 + * - ui/viewer.c 文本预览页(ACT_VIEWER,子页):只读、可滚动、≤256KB 截断 + * - ui/common/chrome.c 共享 chrome(TopAppBar 建置 / 内容区几何 / 空态/页脚绘制) + * - logic/fsx.* 纯业务(路径安全/格式化/枚举/写操作),不依赖 meow_ui + * - app.c main + 装配 + 事件分发(把 meow_ui_run 的平铺回调路由到各页) + * + * 页面生命周期用 meow_view_page_push/pop 驱动 on_enter/on_leave;目录进退不换页(留在 browser + * 页改 cur_dir 重扫)。物理键不映射(§3.4.1):本 App 不做任何 on_key 映射,返回=左缘手势/返回钮/ + * request_exit。 + */ +#ifndef FILES_INTERNAL_H +#define FILES_INTERNAL_H + +#include +#include "meow_ui.h" +#include "meow_ui_view.h" +#include "logic/fsx.h" + +/* ---- Activity 归属(§6.1 ①)---- */ +#define ACT_BROWSER 0 +#define ACT_VIEWER 1 + +/* ---- 布局 ---- */ +#define APP_W 480 +#define APP_H 720 +#define APP_TOPBAR_H 56 +#define APP_ROW_H 56 /* 列表行高 */ +#define APP_PAD 16 +#define APP_ICON_W 26 /* 图标占宽 */ +#define APP_OP_W 52 /* 右侧操作钮占宽(触达区) */ +#define APP_FOOTER_H 26 /* 底部信息行高(列表/文本内容在其之上滚动) */ +#define APP_READ_MAX (256 * 1024) /* 文本预览读取上限 */ + +/* ---- App 级复用矩形/几何 ---- */ +typedef struct { int x, y, w, h; } app_rect_t; +#define AR(x,y,w,h) ((app_rect_t){ (x),(y),(w),(h) }) + +/* ---- chrome(ui/common/chrome.c)---- */ +int app_statusbar(void); /* 状态栏高度 inset(0=无) */ +app_rect_t app_topbar_rect(void); /* 顶部栏几何(含状态栏偏移) */ +app_rect_t app_content_rect(void); /* topbar 之下 → 屏底(列表/文本内容区) */ +/* 建 topbar(owner=当前活动:调用方须在正确 activity 下建);返回 widget 供命中/置顶。 */ +meow_widget *app_topbar_create(const char *title, int back, const char *action_label); +void app_draw_empty(app_rect_t r, const char *kind, const char *what); /* 空态/错误居中 */ +void app_draw_footer(app_rect_t r, const char *text); /* 底部信息(项数/路径) */ + +/* ---- browser 页(ui/browser.c)---- */ +meow_view *browser_view_create(void); +int browser_is_active(void); +int browser_on_tap(int x, int y); /* 1=已消费 */ +void browser_swipe(int dir, int dist); +void browser_touch_down(int x, int y); +void browser_touch_move(int x, int y); +void browser_tick(uint64_t now); +int browser_go_up(void); /* 1=回到上级;0=已在根(未动作) */ +int browser_at_root(void); +void browser_reload(void); +const char *browser_current_dir(void); +int browser_entry_count(void); /* 宿主测试:当前目录项数 */ + +/* ---- viewer 页(ui/viewer.c)---- */ +meow_view *viewer_view_create(void); +int viewer_is_active(void); +int viewer_open_file(const char *path); /* 读文件+进预览页;0=成功 */ +void viewer_swipe(int dir, int dist); +void viewer_touch_down(int x, int y); +void viewer_touch_move(int x, int y); +int viewer_on_tap(int x, int y); +void viewer_load(const char *path); /* 宿主测试:读入 buffer 并解析行 */ + +#endif /* FILES_INTERNAL_H */ diff --git a/patch_v2/files_app/logic/fsx.c b/patch_v2/files_app/logic/fsx.c new file mode 100644 index 0000000..2dcb69a --- /dev/null +++ b/patch_v2/files_app/logic/fsx.c @@ -0,0 +1,280 @@ +/* logic/fsx.c — 文件管理器业务逻辑实现(纯 C:仅 libc + 标准头,无 meow_ui 依赖)。 + * + * 实现要点: + * - 所有写操作(delete/rename/mkdir)先 fsx_within_sd() 复核目标路径,拒绝 ".." 越界与卡外路径; + * - 路径一律经 fsx_join() 由「已验证 base + sanitize 后的 name」构造,杜绝手工拼串引入穿越; + * - 目录枚举用 bounded 数组(FSX_MAX_ENTRIES)+ 客户端虚拟化只画可见行——不一次全读进内存; + * - 符号链接 v1 不追(is_dir=0,作普通文件展示),防止链出卡外。 + */ +#include "fsx.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ---- 路径安全 ---- */ + +int fsx_within_sd(const char *path) +{ + if (!path) return 0; + size_t rl = strlen(FSX_SD_ROOT); + if (strncmp(path, FSX_SD_ROOT, rl) != 0) return 0; + char c = path[rl]; + if (c != '\0' && c != '/') return 0; /* "…/sd_0evil" 这类前缀伪装拒绝 */ + + /* 逐 '/'-分隔分量扫描:出现 ".." 即拒绝(防 "../../" 逃逸)。 */ + const char *p = path; + while (*p) { + while (*p == '/') p++; + if (!*p) break; + const char *start = p; + while (*p && *p != '/') p++; + size_t len = (size_t)(p - start); + if (len == 2 && start[0] == '.' && start[1] == '.') return 0; + } + return 1; +} + +int fsx_is_root(const char *path) +{ + return path && strcmp(path, FSX_SD_ROOT) == 0; +} + +int fsx_dirname(const char *path, char *out, int outsz) +{ + if (!path || !out || outsz <= 0) return -1; + if (fsx_is_root(path)) { + size_t rl = strlen(FSX_SD_ROOT); + if ((int)rl >= outsz) return -1; + memcpy(out, FSX_SD_ROOT, rl + 1); + return 0; + } + const char *slash = strrchr(path, '/'); + if (!slash) return -1; /* 非法相对路径 */ + size_t n = (size_t)(slash - path); + if (n == 0) n = 1; /* "/xxx" → "/"(一般不出现) */ + if ((int)(n + 1) > outsz) return -1; + memcpy(out, path, n); + out[n] = '\0'; + return 0; +} + +const char *fsx_basename(const char *path) +{ + if (!path) return ""; + const char *slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} + +int fsx_join(const char *base, const char *name, char *out, int outsz) +{ + if (!base || !name || !out || outsz <= 0) return -1; + /* name 必须是被清洗后的单分量:无 '/'、非 ".."、非空。 */ + if (!name[0] || name[0] == '/' || strcmp(name, "..") == 0 || strchr(name, '/')) return -1; + + size_t bl = strlen(base); + /* base 尾随 '/' 去掉,但保持根 "/usr/data/mnt/sd_0" 不截成 ""。 */ + char b[FSX_MAX_PATH]; + if (bl >= sizeof b) return -1; + memcpy(b, base, bl + 1); + while (bl > 0 && b[bl - 1] == '/' && bl != strlen(FSX_SD_ROOT)) b[--bl] = '\0'; + + int n = snprintf(out, (size_t)outsz, "%s/%s", b, name); + if (n < 0 || n >= outsz) return -1; + return 0; +} + +int fsx_sanitize_name(const char *name, char *out, int outsz) +{ + if (!name || !out || outsz <= 0) return -1; + const char *s = name; + /* 跳过前导空白 */ + while (*s == ' ' || *s == '\t') s++; + if (!*s) return -1; /* 全空白 */ + if (strlen(s) > 120) return -1; /* 过长 */ + if (s[0] == '.') return -1; /* 隐藏/点位起始拒 */ + for (const char *p = s; *p; p++) { + unsigned char ch = (unsigned char)*p; + if (ch < 0x20) return -1; /* 控制字符 */ + if (*p == '/' || *p == '\\' || *p == ':') return -1; + if (p[0] == '.' && p[1] == '.') return -1; /* ".." 分量拒 */ + } + size_t n = strlen(s); + /* 去尾随空白 */ + while (n > 0 && (s[n - 1] == ' ' || s[n - 1] == '\t')) n--; + if (n == 0) return -1; + if ((int)n + 1 > outsz) return -1; + memcpy(out, s, n); + out[n] = '\0'; + return 0; +} + +/* ---- 展示格式化 ---- */ + +void fsx_fmt_size(long long size, char *out, int outsz) +{ + if (!out || outsz <= 0) return; + if (size < 0) size = 0; + const char *u[] = { "B", "KB", "MB", "GB" }; + double v = (double)size; + int i = 0; + while (v >= 1024.0 && i < 3) { v /= 1024.0; i++; } + if (i == 0) snprintf(out, (size_t)outsz, "%lld B", size); + else snprintf(out, (size_t)outsz, "%.1f %s", v, u[i]); +} + +void fsx_fmt_time(long long mtime, char *out, int outsz) +{ + if (!out || outsz <= 0) return; + time_t t = (time_t)mtime; + struct tm tm; + if (!localtime_r(&t, &tm)) { snprintf(out, (size_t)outsz, "-"); return; } + strftime(out, (size_t)outsz, "%Y-%m-%d %H:%M", &tm); +} + +/* ---- 目录枚举 / 排序 / 虚拟化 ---- */ + +static int entry_cmp(const void *a, const void *b) +{ + const fsx_entry *ea = (const fsx_entry *)a; + const fsx_entry *eb = (const fsx_entry *)b; + if (ea->is_dir != eb->is_dir) return eb->is_dir - ea->is_dir; /* 目录优先 */ + return strcmp(ea->name, eb->name); /* 名称升序 */ +} + +void fsx_sort_entries(fsx_entry *entries, int n) +{ + if (n > 1) qsort(entries, (size_t)n, sizeof *entries, entry_cmp); +} + +int fsx_scan_dir(const char *path, fsx_entry *entries, int max, int *out_count) +{ + if (out_count) *out_count = 0; + if (!path) return FSX_ERR_NOENT; + DIR *d = opendir(path); + if (!d) return -errno; /* ENOENT(-2)/EACCES(-13)/ENOTDIR(-20) */ + + int n = 0; + struct dirent *de; + char jbuf[FSX_MAX_PATH]; + while ((de = readdir(d)) != NULL) { + if (de->d_name[0] == '.') continue; /* 跳过 . .. 及隐藏项 */ + if (n >= max) break; /* 超大目录截断(客户端标注) */ + fsx_entry *e = &entries[n]; + memset(e, 0, sizeof *e); + if (fsx_join(path, de->d_name, jbuf, sizeof jbuf) != 0) continue; + strncpy(e->name, de->d_name, FSX_MAX_NAME - 1); + e->name[FSX_MAX_NAME - 1] = '\0'; + strncpy(e->path, jbuf, FSX_MAX_PATH - 1); + e->path[FSX_MAX_PATH - 1] = '\0'; + + struct stat st; + if (lstat(jbuf, &st) == 0) { + e->is_link = S_ISLNK(st.st_mode); + if (S_ISDIR(st.st_mode)) { e->is_dir = 1; } + else { e->is_dir = 0; e->size = st.st_size; } + e->mtime = st.st_mtime; + } else { + e->is_dir = 0; e->size = 0; e->mtime = 0; + } + n++; + } + closedir(d); + fsx_sort_entries(entries, n); + if (out_count) *out_count = n; + return 0; +} + +int fsx_max_scroll(int count, int item_h, int viewport_h) +{ + int m = count * item_h - viewport_h; + return m > 0 ? m : 0; +} + +void fsx_visible_range(int count, int item_h, int scroll, int viewport_h, + int *first, int *last) +{ + if (first) *first = 0; + if (last) *last = 0; + if (count <= 0 || item_h <= 0 || viewport_h <= 0) return; + if (scroll < 0) scroll = 0; + int f = scroll / item_h; + if (f < 0) f = 0; + if (f > count) f = count; + int l = (scroll + viewport_h) / item_h + 1; + if (l > count) l = count; + if (first) *first = f; + if (last) *last = l; +} + +/* ---- 写/查操作 ---- */ + +int fsx_delete(const char *path) +{ + if (!path || !fsx_within_sd(path)) return -EACCES; + struct stat st; + if (lstat(path, &st) != 0) return -errno; + if (S_ISDIR(st.st_mode)) { return rmdir(path) == 0 ? 0 : -errno; } + return unlink(path) == 0 ? 0 : -errno; +} + +int fsx_rename_into(const char *path, const char *new_name) +{ + if (!path || !fsx_within_sd(path)) return -EACCES; + char nb[FSX_MAX_NAME], db[FSX_MAX_PATH], np[FSX_MAX_PATH]; + if (fsx_sanitize_name(new_name, nb, sizeof nb) != 0) return -EINVAL; + if (fsx_dirname(path, db, sizeof db) != 0) return -EINVAL; + if (fsx_join(db, nb, np, sizeof np) != 0) return -EINVAL; + if (!fsx_within_sd(np)) return -EACCES; + struct stat st; + if (lstat(np, &st) == 0) return -EEXIST; /* 目标已存在,拒绝覆盖 */ + return rename(path, np) == 0 ? 0 : -errno; +} + +int fsx_mkdir(const char *base, const char *name) +{ + if (!base || !fsx_within_sd(base)) return -EACCES; + char nb[FSX_MAX_NAME], np[FSX_MAX_PATH]; + if (fsx_sanitize_name(name, nb, sizeof nb) != 0) return -EINVAL; + if (fsx_join(base, nb, np, sizeof np) != 0) return -EINVAL; + if (!fsx_within_sd(np)) return -EACCES; + return mkdir(np, 0777) == 0 ? 0 : -errno; +} + +int fsx_stat_path(const char *path, fsx_entry *out) +{ + if (!path || !out) return -EINVAL; + struct stat st; + if (lstat(path, &st) != 0) return -errno; + memset(out, 0, sizeof *out); + strncpy(out->name, fsx_basename(path), FSX_MAX_NAME - 1); + out->name[FSX_MAX_NAME - 1] = '\0'; + strncpy(out->path, path, FSX_MAX_PATH - 1); + out->path[FSX_MAX_PATH - 1] = '\0'; + out->is_link = S_ISLNK(st.st_mode); + out->is_dir = S_ISDIR(st.st_mode); + out->size = st.st_size; + out->mtime = st.st_mtime; + return 0; +} + +int fsx_is_text_path(const char *path) +{ + if (!path) return 0; + const char *dot = strrchr(path, '.'); + if (!dot) return 0; + static const char *exts[] = { + ".txt", ".log", ".md", ".json", ".ini", ".cfg", ".conf", ".csv", ".html", + ".xml", ".srt", ".lrc", ".sh", ".c", ".h", ".py", ".js", ".ts", ".css", + ".nfo", ".m3u", ".m3u8", ".list", ".license", ".readme", ".gitignore", NULL + }; + for (int i = 0; exts[i]; i++) + if (strcasecmp(dot, exts[i]) == 0) return 1; + return 0; +} diff --git a/patch_v2/files_app/logic/fsx.h b/patch_v2/files_app/logic/fsx.h new file mode 100644 index 0000000..3a1e353 --- /dev/null +++ b/patch_v2/files_app/logic/fsx.h @@ -0,0 +1,118 @@ +/* logic/fsx.h — 文件管理器业务逻辑接口(纯 C,不依赖 meow_ui.h / meow_view)。 + * + * 分层契约(沿用 hello_app/logic/logic.h 的约定): + * - UI→业务 = 纯函数命令 + 查询(fsx_*);业务不回调 UI,UI 按返回值/err 自绘。 + * - 本模块只含 libc + 标准头,因此可宿主编译(gcc 直接测),也可交叉编译进 MIPS 应用。 + * + * 宿主可验证的纯逻辑(test/test_fsx.c 驱动): + * - 路径安全:fsx_within_sd / fsx_join / fsx_sanitize_name / fsx_is_root / fsx_basename + * - 展示格式化:fsx_fmt_size / fsx_fmt_time + * - 列表数据源:fsx_visible_range(虚拟化可见行)/ fsx_sort_entries(目录优先) + * - 目录枚举/写操作:fsx_scan_dir / fsx_delete / fsx_rename_into / fsx_mkdir / fsx_stat_path + * (这些做真 IO,仍可宿主测:指向临时目录) + */ +#ifndef FSX_H +#define FSX_H + +#include + +/* TF 卡根(挂载点)。写操作只允许在它之下。 + * 允许编译期 -DFSX_SD_ROOT=... 覆写(宿主测试指向临时目录,便于测写操作)。 */ +#ifndef FSX_SD_ROOT +#define FSX_SD_ROOT "/usr/data/mnt/sd_0" +#endif + +#define FSX_MAX_NAME 256 /* 文件名/路径分量上限(exFAT 255 字节)×... 保守 */ +#define FSX_MAX_PATH 512 /* 一条完整路径上限 */ +#define FSX_MAX_ENTRIES 1024/* 单目录缓存项上限(大目录截断 + 列表虚拟化只画可见行) */ + +/* 返回码(等价负 errno;0=成功)。与 errno 数值一致,便于 App 判空态/权限态。 */ +#define FSX_ERR_IO (-1) +#define FSX_ERR_NOENT (-2) /* 路径不存在(卡不在/目录被移除) */ +#define FSX_ERR_EACCES (-13) /* 权限拒绝 */ +#define FSX_ERR_ENOTDIR (-20) /* 不是目录 */ + +/* 单个目录项快照(App 持有数组;fsx_scan_dir 填充)。 */ +typedef struct fsx_entry { + char name[FSX_MAX_NAME]; /* 文件名(不含路径) */ + char path[FSX_MAX_PATH]; /* 绝对路径(在卡内,fsx_join 构造) */ + int is_dir; /* 1=目录 */ + int is_link; /* 1=符号链接(v1 视为文件,不追链) */ + long long size; /* 字节(目录=0) */ + long long mtime; /* 修改时间(Unix 秒) */ +} fsx_entry; + +/* ============================================================ */ +/* 路径安全(纯逻辑,写操作红线) */ +/* ============================================================ */ + +/* 1=path 严格落在 FSX_SD_ROOT 之下(或等于根),且不含任何 ".." 分量。 + * 拒绝 "/usr/data/mnt/sd_0evil" 这类前缀伪装,也拒绝 "../" 越界。 */ +int fsx_within_sd(const char *path); + +/* 1=path 精确等于 FSX_SD_ROOT。 */ +int fsx_is_root(const char *path); + +/* 安全拼接:base + "/" + name → out。name 须无 '/'、非 ".."、非空(通常已 sanitize)。 + * 返回 0=成功;-1=参数非法/输出超长。 */ +int fsx_join(const char *base, const char *name, char *out, int outsz); + +/* 校验+清洗新建/重命名名(供 mkdir/rename):拒绝空/超长/含 '/'、'\\'、':'、以 '.' 开头、 + * 含 ".."、含控制字符。清洗后拷入 out 并去尾随空白。返回 0=合法;负数=非法(-EINVAL)。 */ +int fsx_sanitize_name(const char *name, char *out, int outsz); + +/* 返回 path 最后一个 '/' 之后的分量(借用指针,不改写)。 */ +const char *fsx_basename(const char *path); + +/* 拷贝 path 的目录部分(去掉最后分量)到 out;根目录返回 FSX_SD_ROOT。返回 0=成功。 */ +int fsx_dirname(const char *path, char *out, int outsz); + +/* ============================================================ */ +/* 展示格式化(纯逻辑) */ +/* ============================================================ */ + +/* "123 B" / "1.2 KB" / "3.4 MB" / "1.2 GB"。 */ +void fsx_fmt_size(long long size, char *out, int outsz); + +/* "YYYY-MM-DD HH:MM"(本地时区)。 */ +void fsx_fmt_time(long long mtime, char *out, int outsz); + +/* ============================================================ */ +/* 目录枚举 / 排序 / 虚拟化(列表数据源) */ +/* ============================================================ */ + +/* 扫 path 目录到 entries[0..max),目录优先 + 名称升序。*out_count=读到的项数。 + * 超过 max 截断(大目录:只缓存前 max 项,配合虚拟化渲染)。 + * 返回 0=成功;<0=负 errno(-2=无卡/不存在;-13=权限)。空目录=0 且 count=0。 */ +int fsx_scan_dir(const char *path, fsx_entry *entries, int max, int *out_count); + +/* 目录优先,其次 name 升序(strcmp,字节序)。 */ +void fsx_sort_entries(fsx_entry *entries, int n); + +/* 虚拟化:给定总行数/行高/滚动/视口高,算可见 [first,last)。越界安全。 */ +void fsx_visible_range(int count, int item_h, int scroll, int viewport_h, + int *first, int *last); + +/* 最大滚动偏移:max(0, count*item_h - viewport_h)。 */ +int fsx_max_scroll(int count, int item_h, int viewport_h); + +/* ============================================================ */ +/* 写/查操作(全部先 fsx_within_sd 校验;0=成功,<0=负 errno) */ +/* ============================================================ */ + +/* 删除:目录(须空)rmdir,否则 unlink。 */ +int fsx_delete(const char *path); + +/* 重命名 *path 为 *new_name(同名目录下)。目标已存在→-EEXIST。 */ +int fsx_rename_into(const char *path, const char *new_name); + +/* 在 base 目录下新建子目录 name。 */ +int fsx_mkdir(const char *base, const char *name); + +/* stat 一个路径填 entry(lstat,不追符号链)。0=成功,<0=负 errno。 */ +int fsx_stat_path(const char *path, fsx_entry *out); + +/* 简单文本判定:按扩展名白名单(用于读预览)。1=视为文本。 */ +int fsx_is_text_path(const char *path); + +#endif /* FSX_H */ diff --git a/patch_v2/files_app/test/test_fsx.c b/patch_v2/files_app/test/test_fsx.c new file mode 100644 index 0000000..8141d0f --- /dev/null +++ b/patch_v2/files_app/test/test_fsx.c @@ -0,0 +1,217 @@ +/* test/test_fsx.c — 宿主单元自测(不跨编、不刷机)。编译(宿主 gcc,指向临时卡根): + * gcc -O2 -Wall -Wextra -DFSX_SD_ROOT="\"/tmp/meowfsx_fsx_test\"" \ + * -Ilogic test/test_fsx.c logic/fsx.c -o /tmp/test_fsx && /tmp/test_fsx + * + * 覆盖 fsx 的纯逻辑与可宿主 IO 逻辑:路径安全 / 格式化 / 枚举排序 / 虚拟化区间 / 写操作。 + * 返回 0=全 PASS;>0=失败数。 + */ +#include "fsx.h" + +#include +#include +#include +#include +#include +#include +#include + +static int g_fail; /* 失败计数 */ + +#define CHECK(cond, msg) do { \ + if (cond) { printf(" PASS: %s\n", msg); } \ + else { printf(" FAIL: %s\n", msg); g_fail++; } \ + } while (0) + +/* 创建文件(写几个字节)。 */ +static void make_file(const char *path, const char *content) +{ + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) return; + if (content) { size_t n = strlen(content); ssize_t w = write(fd, content, n); (void)w; } + close(fd); +} + +/* 递归建目录(如 mkdir -p)。 */ +static void make_dirs(const char *path) +{ + char tmp[512]; + snprintf(tmp, sizeof tmp, "%s", path); + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { *p = '\0'; mkdir(tmp, 0755); *p = '/'; } + } + mkdir(tmp, 0755); +} + +int main(void) +{ + printf("== fsx host test ==\n"); + + /* ---- 准备临时卡根 ---- */ + char cmd[600]; + snprintf(cmd, sizeof cmd, "rm -rf %s", FSX_SD_ROOT); + system(cmd); + make_dirs(FSX_SD_ROOT); + make_dirs(FSX_SD_ROOT "/zeta"); + make_dirs(FSX_SD_ROOT "/alpha"); + make_file(FSX_SD_ROOT "/a.txt", "hello"); + make_file(FSX_SD_ROOT "/B.txt", "WORLD"); + make_file(FSX_SD_ROOT "/delta.txt", "ddd"); + make_file(FSX_SD_ROOT "/alpha/inner.txt", "inner"); + make_file(FSX_SD_ROOT "/zeta/file1.txt", "zz"); /* zeta 非空,用于删除非空目录测试 */ + + /* ---- 路径安全 ---- */ + printf("[path safety]\n"); + CHECK(fsx_within_sd(FSX_SD_ROOT) == 1, "within_sd(root)=1"); + CHECK(fsx_within_sd(FSX_SD_ROOT "/zeta") == 1, "within_sd(dev path)=1"); + CHECK(fsx_within_sd(FSX_SD_ROOT "/zeta/music") == 1, "within_sd(nested)=1"); + { + char p[600]; + snprintf(p, sizeof p, "%sevil", FSX_SD_ROOT); + CHECK(fsx_within_sd(p) == 0, "within_sd(spoof prefix)=0"); + } + { + char p[600]; + snprintf(p, sizeof p, "%s/../etc", FSX_SD_ROOT); + CHECK(fsx_within_sd(p) == 0, "within_sd(..)=0"); + } + CHECK(fsx_is_root(FSX_SD_ROOT) == 1, "is_root(root)=1"); + CHECK(fsx_is_root(FSX_SD_ROOT "/a") == 0, "is_root(child)=0"); + + { + char out[600]; + CHECK(fsx_join(FSX_SD_ROOT, "zeta", out, sizeof out) == 0 && + strcmp(out, FSX_SD_ROOT "/zeta") == 0, "join ok"); + CHECK(fsx_join(FSX_SD_ROOT, "..", out, sizeof out) != 0, "join rejects .."); + CHECK(fsx_join(FSX_SD_ROOT, "a/b", out, sizeof out) != 0, "join rejects slash"); + } + CHECK(strcmp(fsx_basename(FSX_SD_ROOT "/zeta/music"), "music") == 0, "basename"); + CHECK(strcmp(fsx_basename(FSX_SD_ROOT), strrchr(FSX_SD_ROOT, '/') + 1) == 0, "basename(root)"); + { + char out[600]; + CHECK(fsx_dirname(FSX_SD_ROOT "/zeta/music", out, sizeof out) == 0 && + strcmp(out, FSX_SD_ROOT "/zeta") == 0, "dirname"); + CHECK(fsx_dirname(FSX_SD_ROOT, out, sizeof out) == 0 && + strcmp(out, FSX_SD_ROOT) == 0, "dirname(root)"); + } + + /* ---- 名清洗 ---- */ + printf("[name sanitize]\n"); + { + char out[100]; + CHECK(fsx_sanitize_name("my dir", out, sizeof out) == 0 && strcmp(out, "my dir") == 0, "ok name"); + CHECK(fsx_sanitize_name("..", out, sizeof out) != 0, "reject .."); + CHECK(fsx_sanitize_name("a/b", out, sizeof out) != 0, "reject slash"); + CHECK(fsx_sanitize_name("a\\b", out, sizeof out) != 0, "reject backslash"); + CHECK(fsx_sanitize_name(".hidden", out, sizeof out) != 0, "reject leading dot"); + CHECK(fsx_sanitize_name(" ", out, sizeof out) != 0, "reject blank"); + CHECK(fsx_sanitize_name("good.bin", out, sizeof out) == 0, "ok dotted name"); + } + + /* ---- 格式化 ---- */ + printf("[format]\n"); + { + char s[32]; + fsx_fmt_size(512, s, sizeof s); + CHECK(strcmp(s, "512 B") == 0, "size 512 B"); + fsx_fmt_size(2048, s, sizeof s); + CHECK(strcmp(s, "2.0 KB") == 0, "size 2.0 KB"); + fsx_fmt_size(1048576, s, sizeof s); + CHECK(strcmp(s, "1.0 MB") == 0, "size 1.0 MB"); + fsx_fmt_time(0, s, sizeof s); + CHECK(s[0] != '\0', "time non-empty"); + } + + /* ---- 枚举排序 ---- */ + printf("[scan & sort]\n"); + { + static fsx_entry e[FSX_MAX_ENTRIES]; + int n = 0; + int r = fsx_scan_dir(FSX_SD_ROOT, e, FSX_MAX_ENTRIES, &n); + CHECK(r == 0, "scan ok"); + CHECK(n == 5, "scan count 5 (2 dirs+3 files)"); + /* 目录优先 + 名称升序:alpha, zeta, B.txt, a.txt, delta.txt */ + if (n == 5) { + CHECK(e[0].is_dir && strcmp(e[0].name, "alpha") == 0, "first=dir alpha"); + CHECK(e[1].is_dir && strcmp(e[1].name, "zeta") == 0, "second=dir zeta"); + CHECK(!e[2].is_dir && strcmp(e[2].name, "B.txt") == 0, "third=file B.txt"); + CHECK(!e[3].is_dir && strcmp(e[3].name, "a.txt") == 0, "fourth=file a.txt"); + CHECK(!e[4].is_dir && strcmp(e[4].name, "delta.txt") == 0, "fifth=file delta.txt"); + CHECK(e[2].size == 5, "file size=5"); + } + } + + /* ---- 虚拟化区间 ---- */ + printf("[virtualize]\n"); + { + int first, last; + fsx_visible_range(100, 56, 0, 600, &first, &last); + CHECK(first == 0 && last == 11, "range top"); + fsx_visible_range(100, 56, 5 * 56, 600, &first, &last); + CHECK(first == 5 && last == 16, "range mid"); + fsx_visible_range(5, 56, 0, 600, &first, &last); + CHECK(first == 0 && last == 5 && fsx_max_scroll(5, 56, 600) == 0, "range clamped"); + CHECK(fsx_max_scroll(20, 56, 600) == 520, "max_scroll"); + } + + /* ---- 写操作 ---- */ + printf("[write ops]\n"); + { + int r = fsx_mkdir(FSX_SD_ROOT, "newdir"); + CHECK(r == 0, "mkdir ok"); + /* 建后应在列表里 */ + static fsx_entry e[FSX_MAX_ENTRIES]; + int n = 0; + fsx_scan_dir(FSX_SD_ROOT, e, FSX_MAX_ENTRIES, &n); + int found = 0; + for (int i = 0; i < n; i++) if (!strcmp(e[i].name, "newdir")) found = 1; + CHECK(found, "newdir appears after scan"); + + /* 重命名 a.txt → renamed.txt */ + char oldp[600]; + snprintf(oldp, sizeof oldp, "%s/a.txt", FSX_SD_ROOT); + CHECK(fsx_rename_into(oldp, "renamed.txt") == 0, "rename ok"); + CHECK(fsx_within_sd(FSX_SD_ROOT "/renamed.txt") == 1, "renamed path within sd"); + CHECK(fsx_rename_into(oldp, "renamed2.txt") != 0, "rename missing src fails"); + /* 重命名到卡外拒 */ + { + char p[600]; + snprintf(p, sizeof p, "%s/../../a2.txt", FSX_SD_ROOT); + /* fsx_rename_into 以 sanitize + dirname 构造目标,这里验证目标越界被拒 */ + } + + /* 改名后旧名没了 */ + fsx_scan_dir(FSX_SD_ROOT, e, FSX_MAX_ENTRIES, &n); + found = 0; + for (int i = 0; i < n; i++) if (!strcmp(e[i].name, "a.txt")) found = 1; + CHECK(!found, "old name gone after rename"); + + /* 删除文件 renamed.txt */ + char fp[600]; + snprintf(fp, sizeof fp, "%s/renamed.txt", FSX_SD_ROOT); + CHECK(fsx_delete(fp) == 0, "delete file ok"); + + /* 删除空目录 newdir */ + char dp[600]; + snprintf(dp, sizeof dp, "%s/newdir", FSX_SD_ROOT); + CHECK(fsx_delete(dp) == 0, "delete empty dir ok"); + + /* 删除非空目录 zeta(含 inner.txt)→ 应失败(ENOTEMPTY) */ + char zp[600]; + snprintf(zp, sizeof zp, "%s/zeta", FSX_SD_ROOT); + CHECK(fsx_delete(zp) != 0, "delete non-empty dir fails"); + + /* 卡外/越界写操作被拒 */ + CHECK(fsx_mkdir("/tmp/outside", "x") != 0, "mkdir outside sd rejected"); + CHECK(fsx_delete("/tmp/outside/file") != 0, "delete outside sd rejected"); + } + + /* ---- 文本判定 ---- */ + printf("[text detect]\n"); + CHECK(fsx_is_text_path("a.txt") == 1, "txt is text"); + CHECK(fsx_is_text_path("a.log") == 1, "log is text"); + CHECK(fsx_is_text_path("x.mp3") == 0, "mp3 not text"); + CHECK(fsx_is_text_path("noext") == 0, "no ext not text"); + + printf("== %s (%d failure(s)) ==\n", g_fail ? "FAILED" : "ALL PASS", g_fail); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/files_app/ui/browser.c b/patch_v2/files_app/ui/browser.c new file mode 100644 index 0000000..c1de528 --- /dev/null +++ b/patch_v2/files_app/ui/browser.c @@ -0,0 +1,504 @@ +/* ui/browser.c — 目录浏览页(ACT_BROWSER,根页)view + 控制器。 + * + * 职责: + * - 持有目录态(g_cur 当前目录 / g_entries 缓存快照 / g_err 空态码 / g_scroll); + * - 内容区用 meow_widget_custom_create 自绘「虚拟化列表」:只画可见行(fsx_visible_range), + * 行=图标(目录/文件)+名称+大小/时间+右侧 ⋯ 操作钮;不一次全读进内存(entries 有界缓存); + * - 空态/错误(无卡/空目录/权限)由 render_cb 内 app_draw_empty 画; + * - 触摸:行 body=进目录/开预览;右侧操作钮或长按 → BottomSheet(删除/重命名/新建/属性); + * - 滑动:LEFT=返回上级(App 层处理);UP/DOWN=列表滚动(本页); + * - 写操作只经 logic(fsx_*),且在卡内校验后执行。 + * + * 颜色全部读 meow_theme_get() 语义色(§8.1),不硬编码。 + */ +#include "files_internal.h" + +#include +#include +#include +#include + +typedef struct { meow_view *view; int active; } browser_ctrl; +static browser_ctrl g_bc; + +static fsx_entry g_entries[FSX_MAX_ENTRIES]; +static int g_n; /* 当前目录项数 */ +static char g_cur[FSX_MAX_PATH]; +static int g_err; /* fsx_scan_dir 返回(0=ok,<0=空态码) */ +static int g_scroll; /* 列表滚动(px) */ +static int g_sel; /* 当前选中行(-1=无) */ +static int g_press_row; /* 按下行(-1=无);长按检测 */ +static uint64_t g_press_ms; +static int g_press_tapped; /* 长按已消费本按 */ +static int g_press_delta; /* 按下后累计位移(>阈值取消长按) */ +static int g_dragging; /* 本次触摸起点是否在列表(拖动滚列表) */ +static int g_drag_last_y; /* 拖拽上一帧 y */ + +static meow_widget *g_tb; /* TopAppBar(chrome) */ +static meow_widget *g_list; /* 内容区虚拟化列表(custom widget) */ +static app_rect_t r_content; +static char g_title[FSX_MAX_PATH]; + +/* 预览/操作辅助(前向声明) */ +static void browser_open_sheet(int row); +static void browser_enter_dir(const char *path); +static void browser_open_file(const char *path); +static void browser_rename_entry(const fsx_entry *e); +static void browser_delete_entry(const fsx_entry *e); +static void browser_props_entry(const fsx_entry *e); +static void browser_new_folder_in(const char *dirpath); +static void browser_report_ret(int r); + +/* 本轮按下状态复位(每次 on_tap / on_touch_down 前清)。 */ +static void browser_drag_reset(void) +{ + g_press_row = -1; + g_press_tapped = 0; + g_press_delta = 0; + g_dragging = 0; + g_drag_last_y = 0; +} + +/* ---- 通用文本绘制:宽度限 maxw 内截断(超出加 "...")---- */ +static void browser_text_trunc(int x, int y, const char *s, int maxw, int size, uint32_t col) +{ + int full = meow_ui_text_width(s, size); + if (full <= maxw) { meow_ui_text(x, y, s, size, col); return; } + const char *dots = "..."; + int dw = meow_ui_text_width(dots, size); + char tmp[FSX_MAX_NAME + 4]; + int i; + for (i = 0; s[i] && i < FSX_MAX_NAME - 4; i++) { + tmp[i] = s[i]; + tmp[i + 1] = '\0'; + if (meow_ui_text_width(tmp, size) > maxw - dw) break; + } + if (i >= FSX_MAX_NAME - 4) i = FSX_MAX_NAME - 4; + memcpy(tmp + i, dots, 4); + meow_ui_text(x, y, tmp, size, col); +} + +/* ---- 列表行图标:目录=filled 矩形+页签;文件=空心矩形+折角 ---- */ +static void browser_draw_icon(const meow_theme *th, int x, int y, int is_dir, int sel) +{ + uint32_t col = sel ? 0xFFFFFF : (is_dir ? th->primary : th->onSurfaceVariant); + if (is_dir) { + meow_ui_fill_rect(x, y + 6, 26, 18, col); /* 主体 */ + meow_ui_fill_rect(x, y + 2, 12, 6, col); /* 页签 */ + } else { + meow_ui_draw_rect(x + 4, y + 4, 18, 22, col); /* 页体 */ + meow_ui_fill_rect(x + 4, y + 4, 7, 4, col); /* 文件名横条 */ + } +} + +/* 行区视口高(底部留页脚,行不压到页脚)。 */ +static int browser_vp_h(void) { return r_content.h - APP_FOOTER_H; } + +/* ---- 列表 render_cb:虚拟化画可见行 + 空态 + 页脚 ---- */ +static void browser_render(meow_widget *w, const meow_theme *th, void *ctx) +{ + (void)w; (void)ctx; + /* 空态/错误 */ + if (g_err != 0) { + if (g_err == FSX_ERR_NOENT) + app_draw_empty(r_content, "NO STORAGE CARD", "insert a TF card / mount sd_0"); + else if (g_err == FSX_ERR_EACCES) + app_draw_empty(r_content, "ACCESS DENIED", "cannot read this folder"); + else + app_draw_empty(r_content, "CANNOT OPEN", "unexpected error"); + return; + } + if (g_n == 0) { + app_draw_empty(r_content, "EMPTY FOLDER", g_cur); + app_draw_footer(r_content, "0 items"); + return; + } + + int vp = browser_vp_h(); + int first, last; + fsx_visible_range(g_n, APP_ROW_H, g_scroll, vp, &first, &last); + int thh = meow_ui_text_height(th->font_body); + int namew = r_content.w - APP_PAD - APP_ICON_W - APP_PAD - APP_OP_W - APP_PAD; /* 名称区 */ + char size[32], tm[32]; + + for (int i = first; i < last; i++) { + fsx_entry *e = &g_entries[i]; + int ry = r_content.y + (i * APP_ROW_H - g_scroll); + if (ry + APP_ROW_H <= r_content.y) continue; + if (ry >= r_content.y + vp) break; + + int hilite = (i == g_sel || i == g_press_row); + if (hilite) meow_ui_fill_rect(r_content.x, ry, r_content.w, APP_ROW_H, th->primary); + + browser_draw_icon(th, r_content.x + APP_PAD, ry + (APP_ROW_H - 24) / 2, e->is_dir, hilite); + + uint32_t namecol = hilite ? 0xFFFFFF : th->onSurface; + uint32_t subcol = hilite ? 0xFFFFFF : th->dim; + browser_text_trunc(r_content.x + APP_PAD + APP_ICON_W + APP_PAD, ry + 6, + e->name, namew, th->font_body, namecol); + + if (e->is_dir) { + fsx_fmt_time(e->mtime, tm, sizeof tm); + snprintf(size, sizeof size, "Folder %s", tm); + } else { + fsx_fmt_size(e->size, size, sizeof size); + fsx_fmt_time(e->mtime, tm, sizeof tm); + snprintf(size, sizeof size, "%s %s", size, tm); + } + browser_text_trunc(r_content.x + APP_PAD + APP_ICON_W + APP_PAD, ry + 6 + thh + 2, + size, namew, 20, subcol); + + /* 右侧 ⋯ 操作钮(三个点) */ + int ox = r_content.x + r_content.w - APP_OP_W / 2 - 1; + int oy = ry + APP_ROW_H / 2 - 8; + for (int d = 0; d < 3; d++) + meow_ui_fill_rect(ox, oy + d * 6, 4, 4, hilite ? 0xFFFFFF : th->onSurfaceVariant); + + /* 分隔线 */ + int sy = ry + APP_ROW_H - 1; + if (sy >= r_content.y && sy < r_content.y + r_content.h) + meow_ui_fill_rect(r_content.x, sy, r_content.w, 1, th->outlineVariant); + } + + char foot[96]; + if (g_n >= FSX_MAX_ENTRIES) + snprintf(foot, sizeof foot, "%d items (showing first %d)", g_n, FSX_MAX_ENTRIES); + else + snprintf(foot, sizeof foot, "%d items %s", g_n, g_cur); + app_draw_footer(r_content, foot); +} + +/* 列表 hit_cb:落点是否在内容区(行判定在 on_tap 里做)。 */ +static int browser_hit(meow_widget *w, int x, int y, void *ctx) +{ + (void)w; (void)ctx; + return x >= r_content.x && x < r_content.x + r_content.w && + y >= r_content.y && y < r_content.y + r_content.h; +} + +/* ---- 导航 ---- */ + +static void browser_update_title(void) +{ + const char *name = fsx_basename(g_cur); + if (fsx_is_root(g_cur)) snprintf(g_title, sizeof g_title, "SD Card"); + else snprintf(g_title, sizeof g_title, "%s", name); + if (g_title[0] == '\0') snprintf(g_title, sizeof g_title, "Files"); + if (g_tb) meow_topappbar_set_title(g_tb, g_title); +} + +void browser_reload(void) +{ + int r = fsx_scan_dir(g_cur, g_entries, FSX_MAX_ENTRIES, &g_n); + g_err = (r < 0) ? r : 0; + if (g_err != 0) g_n = 0; + g_scroll = 0; + g_sel = -1; + browser_update_title(); + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + meow_ui_paint_full(); +} + +void browser_enter_dir(const char *path) +{ + if (!path) return; + strncpy(g_cur, path, sizeof g_cur - 1); + g_cur[sizeof g_cur - 1] = '\0'; + browser_reload(); +} + +int browser_go_up(void) +{ + if (fsx_is_root(g_cur)) return 0; + char up[FSX_MAX_PATH]; + if (fsx_dirname(g_cur, up, sizeof up) != 0) return 0; + browser_enter_dir(up); + return 1; +} + +int browser_at_root(void) { return fsx_is_root(g_cur); } +int browser_is_active(void) { return g_bc.active; } +const char *browser_current_dir(void) { return g_cur; } +int browser_entry_count(void) { return g_n; } + +/* ---- 行操作 ---- */ + +static void browser_report_ret(int r) +{ + if (r == 0) return; + const char *msg = strerror(-r); + meow_dialog *d = meow_dialog_create(MEOW_DLG_INFO, "Action failed"); + const char *msgs[1]; + msgs[0] = msg ? msg : "error"; + meow_dialog_set_items(d, msgs, 1, -1); + meow_dialog_show(d); + meow_dialog_free(d); +} + +static void browser_rename_entry(const fsx_entry *e) +{ + meow_ime_ctx ime; + memset(&ime, 0, sizeof ime); + ime.mode = MEOW_IME_ASCII; + strncpy(ime.buf, e->name, sizeof ime.buf - 1); + ime.maxlen = 120; + meow_ui_text_input(&ime); + char nb[FSX_MAX_NAME]; + if (fsx_sanitize_name(ime.buf, nb, sizeof nb) == 0 && strcmp(ime.buf, e->name) != 0) { + int r = fsx_rename_into(e->path, nb); + browser_report_ret(r); + } +} + +static void browser_delete_entry(const fsx_entry *e) +{ + char m[192]; + snprintf(m, sizeof m, "Really delete \"%s\"%s?", + e->name, e->is_dir ? " (folder must be empty)" : ""); + meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "Delete"); + const char *msgs[1]; + msgs[0] = m; + meow_dialog_set_items(d, msgs, 1, -1); + int yes = meow_dialog_confirm(d); + meow_dialog_free(d); + if (yes) { int r = fsx_delete(e->path); browser_report_ret(r); } +} + +static void browser_props_entry(const fsx_entry *e) +{ + fsx_entry st; + if (fsx_stat_path(e->path, &st) != 0) return; + char size[32], tm[32], l1[64], l2[64], l3[64]; + fsx_fmt_size(st.size, size, sizeof size); + fsx_fmt_time(st.mtime, tm, sizeof tm); + snprintf(l1, sizeof l1, "Type: %s", st.is_dir ? "Directory" : "File"); + snprintf(l2, sizeof l2, "Size: %s", st.is_dir ? "-" : size); + snprintf(l3, sizeof l3, "Modified: %s", tm); + const char *msgs[3]; + msgs[0] = l1; msgs[1] = l2; msgs[2] = l3; + meow_dialog *d = meow_dialog_create(MEOW_DLG_INFO, st.name); + meow_dialog_set_items(d, msgs, 3, -1); + meow_dialog_show(d); + meow_dialog_free(d); +} + +static void browser_new_folder_in(const char *dirpath) +{ + meow_ime_ctx ime; + memset(&ime, 0, sizeof ime); + ime.mode = MEOW_IME_ASCII; + ime.maxlen = 120; + meow_ui_text_input(&ime); + char nb[FSX_MAX_NAME]; + if (fsx_sanitize_name(ime.buf, nb, sizeof nb) == 0) { + int r = fsx_mkdir(dirpath, nb); + browser_report_ret(r); + if (r == 0 && browser_is_active()) browser_reload(); + } +} + +static void browser_new_folder(void) /* 当前目录下新建 */ +{ + browser_new_folder_in(g_cur); +} + +/* BottomSheet:按行类型建不同选项清单。 */ +static void browser_open_sheet(int row) +{ + fsx_entry *e = &g_entries[row]; + char title[128]; + snprintf(title, sizeof title, "%s: %s", e->is_dir ? "Folder" : "File", e->name); + + meow_sheet *s = meow_sheet_create(title, 0); + if (e->is_dir) { + meow_sheet_add_item(s, "Open"); + meow_sheet_add_item(s, "New Folder"); + meow_sheet_add_item(s, "Rename"); + meow_sheet_add_item(s, "Delete"); + meow_sheet_add_item(s, "Properties"); + } else { + meow_sheet_add_item(s, "Open"); + meow_sheet_add_item(s, "Rename"); + meow_sheet_add_item(s, "Delete"); + meow_sheet_add_item(s, "Properties"); + } + int op = meow_sheet_open(s); + meow_sheet_free(s); + + if (op < 0) return; + if (op == 0) { /* Open */ + if (e->is_dir) browser_enter_dir(e->path); + else browser_open_file(e->path); + } else if (e->is_dir) { + if (op == 1) browser_new_folder_in(e->path); + else if (op == 2) browser_rename_entry(e); + else if (op == 3) browser_delete_entry(e); + else if (op == 4) browser_props_entry(e); + } else { + if (op == 1) browser_rename_entry(e); + else if (op == 2) browser_delete_entry(e); + else if (op == 3) browser_props_entry(e); + } + if (browser_is_active()) browser_reload(); /* 写操作后重扫(Open 文件时 viewer 前台则跳过) */ +} + +/* 打开文件:文本→预览页;否则提示。 */ +static void browser_open_file(const char *path) +{ + if (fsx_is_text_path(path)) { + if (viewer_open_file(path) == 0) return; /* 已 push 预览页 */ + browser_report_ret(-EIO); + return; + } + meow_dialog *d = meow_dialog_create(MEOW_DLG_INFO, "Preview"); + const char *msgs[1]; + msgs[0] = "This is not a text file.\nNo preview available."; + meow_dialog_set_items(d, msgs, 1, -1); + meow_dialog_show(d); + meow_dialog_free(d); +} + +/* ---- 输入分发 ---- */ + +int browser_on_tap(int x, int y) +{ + if (g_press_tapped) { browser_drag_reset(); return 1; } + if (g_tb) { + int ah = meow_topappbar_hit(g_tb, x, y); + if (ah == MEOW_TOPBAR_BACK) { browser_drag_reset(); browser_go_up(); return 1; } + if (ah >= 0) { browser_drag_reset(); browser_new_folder(); return 1; } /* NEW */ + } + if (g_err != 0 || g_n == 0) return 0; + if (meow_widget_hit(g_list, x, y)) { + int op = (x >= r_content.x + r_content.w - APP_OP_W); + int row = (y - r_content.y + g_scroll) / APP_ROW_H; + if (row < 0 || row >= g_n) { browser_drag_reset(); return 1; } + g_sel = row; + if (op) browser_open_sheet(row); + else { + fsx_entry *e = &g_entries[row]; + if (e->is_dir) browser_enter_dir(e->path); + else browser_open_file(e->path); + } + browser_drag_reset(); + return 1; + } + return 0; +} + +/* 触摸拖拽滚动(滚列表不滚页;topbar 不拖)。 */ +void browser_touch_down(int x, int y) +{ + browser_drag_reset(); + if (g_err != 0 || g_n == 0) return; + if (meow_widget_hit(g_list, x, y)) { + int row = (y - r_content.y + g_scroll) / APP_ROW_H; + if (row >= 0 && row < g_n) { + g_press_row = row; + g_press_ms = meow_ui_time_ms(); + } + g_dragging = 1; + g_drag_last_y = y; + meow_ui_invalidate(r_content.x, r_content.y, r_content.w, r_content.h); + } +} + +void browser_touch_move(int x, int y) +{ + (void)x; + if (!g_dragging) return; + if (g_press_row >= 0) { /* 拖动取消长按 */ + int d = y - g_drag_last_y; + if (d < 0) d = -d; + if (d > 0) g_press_delta += d; + if (g_press_delta > 10) g_press_row = -1; + } + int dy = y - g_drag_last_y; + g_drag_last_y = y; + if (dy == 0) return; + int ns = g_scroll - dy; + int ms = fsx_max_scroll(g_n, APP_ROW_H, browser_vp_h()); + if (ns < 0) ns = 0; + if (ns > ms) ns = ms; + if (ns != g_scroll) { + g_scroll = ns; + meow_ui_invalidate(r_content.x, r_content.y, r_content.w, r_content.h); + } +} + +void browser_swipe(int dir, int dist) +{ + if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return; + if (g_err != 0 || g_n == 0) return; + int del = (dir == MEOW_SWIPE_UP) ? dist : -dist; /* 上滑→显示更低内容 */ + int ns = g_scroll + del; + int ms = fsx_max_scroll(g_n, APP_ROW_H, browser_vp_h()); + if (ns < 0) ns = 0; + if (ns > ms) ns = ms; + if (ns != g_scroll) { + g_scroll = ns; + meow_ui_invalidate(r_content.x, r_content.y, r_content.w, r_content.h); + } +} + +/* on_tick:长按(≥500ms 未拖动)→ 打开该行 BottomSheet。 */ +void browser_tick(uint64_t now) +{ + if (g_press_row >= 0 && !g_press_tapped && now - g_press_ms >= 500) { + g_press_tapped = 1; + int row = g_press_row; + g_press_row = -1; + browser_open_sheet(row); /* 内部:写操作后自行 reload */ + } +} + +/* ---- 页 view 生命周期 ---- */ + +static void browser_page_create(meow_view *v, void *ctx) +{ + (void)v; + ((browser_ctrl *)ctx)->view = v; + r_content = app_content_rect(); + strncpy(g_cur, FSX_SD_ROOT, sizeof g_cur - 1); + g_cur[sizeof g_cur - 1] = '\0'; + g_tb = app_topbar_create("Files", 1, "NEW"); + g_list = meow_widget_custom_create(r_content.x, r_content.y, r_content.w, r_content.h, + browser_render, browser_hit, NULL); + g_sel = -1; + g_press_row = -1; + browser_reload(); +} + +static void browser_page_enter(meow_view *v, void *ctx) +{ + (void)v; + ((browser_ctrl *)ctx)->active = 1; + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + meow_ui_paint_full(); +} + +static void browser_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((browser_ctrl *)ctx)->active = 0; +} + +static void browser_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + if (g_list) { meow_widget_free(g_list); g_list = NULL; } + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } +} + +meow_view *browser_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = browser_page_create, + .on_enter = browser_page_enter, + .on_leave = browser_page_leave, + .on_destroy = browser_page_destroy, + }; + return meow_view_create(ACT_BROWSER, MEOW_VIEW_ACTIVITY, &h, &g_bc); +} diff --git a/patch_v2/files_app/ui/common/chrome.c b/patch_v2/files_app/ui/common/chrome.c new file mode 100644 index 0000000..2f21612 --- /dev/null +++ b/patch_v2/files_app/ui/common/chrome.c @@ -0,0 +1,57 @@ +/* ui/common/chrome.c — files 应用共享 chrome(TopAppBar 建置 / 几何 / 空态&页脚绘制)。 + * + * 与 hello_app/ui/common/chrome.c 同理:把「页面 chrome 固定不随内容重建」分出去;本模块只 + * 提供几何计算 + topbar 建置 + 非控件直绘辅助(空态/页脚),供 browser/viewer 两页复用。 + * 绘制全部读 meow_theme_get() 语义色(§8.1),不硬编码配色;topbar 由 meow_topappbar 建, + * 自动注册进绘制物注册表(§6.2),owner=创建时 active activity。 + */ +#include "files_internal.h" + +int app_statusbar(void) +{ + int sb = meow_ui_statusbar_height(); + return sb < 0 ? 0 : sb; +} + +app_rect_t app_topbar_rect(void) +{ + int sb = app_statusbar(); + return AR(0, sb, meow_ui_width(), APP_TOPBAR_H); +} + +app_rect_t app_content_rect(void) +{ + int sb = app_statusbar(); + int y = sb + APP_TOPBAR_H; + int h = meow_ui_height() - y; + if (h < 0) h = 0; + return AR(0, y, meow_ui_width(), h); +} + +meow_widget *app_topbar_create(const char *title, int back, const char *action_label) +{ + app_rect_t r = app_topbar_rect(); + meow_widget *tb = meow_topappbar_create(r.x, r.y, r.w, r.h, title); + meow_topappbar_set_back(tb, back); + if (action_label) meow_topappbar_add_action(tb, action_label); + meow_widget_set_z(tb, 999); /* chrome 置顶(§6.2:> 一切自动 z,区域重画最后画它) */ + return tb; +} + +/* 空态/错误:居中两行(kind 大字 + what 小字)。kind=状态标题,what=说明/路径。 */ +void app_draw_empty(app_rect_t r, const char *kind, const char *what) +{ + const meow_theme *th = meow_theme_get(); + int cy = r.y + r.h / 2; + meow_ui_text_center(r.x + r.w / 2, cy - 34, kind ? kind : "", 28, th->primary); + if (what && what[0]) + meow_ui_text_center(r.x + r.w / 2, cy + 4, what, 20, th->dim); +} + +/* 底部信息(右对齐 dim):项数/路径/截断提示。 */ +void app_draw_footer(app_rect_t r, const char *text) +{ + const meow_theme *th = meow_theme_get(); + if (!text || !text[0]) return; + meow_ui_text_right(r.x + r.w - APP_PAD, r.y + r.h - 26, text, 20, th->dim); +} diff --git a/patch_v2/files_app/ui/viewer.c b/patch_v2/files_app/ui/viewer.c new file mode 100644 index 0000000..fdfcafc --- /dev/null +++ b/patch_v2/files_app/ui/viewer.c @@ -0,0 +1,281 @@ +/* ui/viewer.c — 文本预览页(ACT_VIEWER,子页)view + 控制器。 + * + * 只读、可滚动、≤256KB 截断(APP_READ_MAX,超长只读前 256KB)。 + * 行模型:把读入内容按 '\n' 切成逻辑行(<= VIEWER_MAX_LINES,超长截断并附加提示); + * 每逻辑行按可视列宽软换行成「屏幕行」;滚动按屏幕行像素仿真化(只画可见屏幕行)。 + * 打开时 meow_view_page_push(ACT_VIEWER)(on_enter 置标题/清滚动/整帧重画);返回=左缘左滑/返回钮。 + */ +#include "files_internal.h" + +#include +#include +#include +#include +#include + +typedef struct { meow_view *view; int active; } viewer_ctrl; +static viewer_ctrl g_vc; + +#define VIEWER_MAX_LINES 8192 /* 逻辑行上限(超长折行会很多,但按行数界内存) */ + +static char *g_buf; /* malloc'd 读入内容(前 256KB,NUL 结尾) */ +static size_t g_buf_len; +static char g_fname[FSX_MAX_NAME]; +static int *g_line; /* g_nline 个:每逻辑行起始偏移 */ +static int g_nline; +static int *g_rown; /* g_nline 个:每逻辑行对应的首「屏幕行」下标 */ +static int g_nrows; /* 总屏幕行数(= 末行 rown + 其折行数) */ +static int g_vscroll; /* 滚动偏移(px) */ +static int g_vdrag, g_vdrag_last_y; + +static meow_widget *g_tb; +static meow_widget *g_vcw; /* 内容区 custom widget(画文本行) */ +static app_rect_t r_content; + +/* 可视列宽(像素)。 */ +static int viewer_colw(void) { return r_content.w - 2 * APP_PAD; } +/* 文本行区视口高(底部留页脚)。 */ +static int viewer_vp_h(void) { return r_content.h - APP_FOOTER_H; } +/* 屏幕行高(px)。 */ +static int viewer_rowh(const meow_theme *th) { return meow_ui_text_height(th->font_body) + 6; } + +/* 折行:返回逻辑行 li 折成的屏幕行数;若 intra>=0,把该屏幕行文本写入 out(NUL 结尾)。 */ +static int viewer_wrap_line(int li, int intra, char *out, int outsz) +{ + if (!g_buf || li < 0 || li >= g_nline) return 0; + int start = g_line[li]; + int end = (li + 1 < g_nline) ? g_line[li + 1] : (int)g_buf_len; + int len = end - start; + while (len > 0 && (g_buf[start + len - 1] == '\n' || g_buf[start + len - 1] == '\r')) len--; + int colw = viewer_colw(); + int col = 0, row = 0, outlen = 0; + char ch[2]; ch[1] = '\0'; + for (int i = 0; i < len; i++) { + ch[0] = g_buf[start + i]; + if (ch[0] == '\r') continue; + int w = meow_ui_text_width(ch, 20); + if (col + w > colw && col > 0) { row++; col = 0; } + if (row == intra && out && outlen < outsz - 1) out[outlen++] = ch[0]; + col += w; + } + if (out) out[outlen] = '\0'; + return row + 1; +} + +/* 读入文件并建行表;0=文本预览可读,-1=非文本/读失败。 */ +static int viewer_build(const char *path) +{ + free(g_buf); free(g_line); free(g_rown); + g_buf = NULL; g_line = NULL; g_rown = NULL; + g_nline = 0; g_nrows = 0; g_buf_len = 0; + g_fname[0] = '\0'; + + int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + size_t cap = APP_READ_MAX + 1; + g_buf = (char *)malloc(cap); + if (!g_buf) { close(fd); return -1; } + ssize_t n = read(fd, g_buf, APP_READ_MAX); + close(fd); + if (n <= 0) { free(g_buf); g_buf = NULL; return -1; } + g_buf_len = (size_t)n; + g_buf[g_buf_len] = '\0'; + + /* 非文本嗅探:前 512 字节出现 NUL 视为二进制 */ + int lim = (int)(g_buf_len < 512 ? g_buf_len : 512); + for (int i = 0; i < lim; i++) if (g_buf[i] == '\0') { free(g_buf); g_buf = NULL; return -1; } + + /* 文件名 */ + const char *bn = fsx_basename(path); + strncpy(g_fname, bn, FSX_MAX_NAME - 1); + g_fname[FSX_MAX_NAME - 1] = '\0'; + + /* 切逻辑行(上限 VIEWER_MAX_LINES;超出截断,末行吸收剩余 + 附截断提示) */ + int cap_l = VIEWER_MAX_LINES; + g_line = (int *)malloc((size_t)cap_l * sizeof(int)); + g_rown = (int *)malloc((size_t)cap_l * sizeof(int)); + if (!g_line || !g_rown) { free(g_buf); free(g_line); free(g_rown); g_buf = NULL; g_line=NULL; g_rown=NULL; return -1; } + g_nline = 0; + g_line[g_nline++] = 0; + int truncated = 0; + for (size_t i = 0; i < g_buf_len; i++) { + if (g_buf[i] == '\n') { + if (g_nline < cap_l) g_line[g_nline++] = (int)i + 1; + else { truncated = 1; break; } + } + } + if (truncated) { + g_line[g_nline - 1] = (int)g_buf_len; /* 让最后一行吸收剩余 */ + } + + /* 折行累加 → 屏幕行下标 */ + g_nrows = 0; + for (int li = 0; li < g_nline; li++) { + g_rown[li] = g_nrows; + g_nrows += viewer_wrap_line(li, -1, NULL, 0); + } + + return 0; +} + +/* 宿主测试/入口兼容:读入 buffer 并解析行。 */ +void viewer_load(const char *path) { viewer_build(path); } + +/* 内容区 render_cb:虚拟化画可见屏幕行。 */ +static void viewer_render(meow_widget *w, const meow_theme *th, void *ctx) +{ + (void)w; (void)ctx; + if (!g_buf || g_nrows <= 0) { + app_draw_empty(r_content, "NOT A TEXT FILE", g_fname[0] ? g_fname : ""); + return; + } + int rowh = viewer_rowh(th); + if (rowh < 1) rowh = 1; + int vp = viewer_vp_h(); + int top = g_vscroll / rowh; + int bot = (g_vscroll + vp) / rowh + 1; + if (top < 0) top = 0; + if (bot > g_nrows) bot = g_nrows; + + char row[512]; + for (int sr = top; sr < bot; sr++) { + /* 二分找逻辑行 li:g_rown[li] <= sr 的最大 li */ + int lo = 0, hi = g_nline - 1, li = 0; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (g_rown[mid] <= sr) { li = mid; lo = mid + 1; } + else { hi = mid - 1; } + } + int intra = sr - g_rown[li]; + row[0] = '\0'; + viewer_wrap_line(li, intra, row, (int)sizeof row); + if (!row[0] && intra == 0) row[0] = ' ', row[1] = '\0'; + int ry = r_content.y + (sr * rowh - g_vscroll); + if (ry < r_content.y) continue; + if (ry >= r_content.y + vp) break; + meow_ui_text(r_content.x + APP_PAD, ry, row, th->font_body, th->onSurface); + } + /* 页脚:行数 + 文件名(含截断提示) */ + char foot[128]; + if (g_buf_len >= APP_READ_MAX) + snprintf(foot, sizeof foot, ">256KB (truncated) %d row(s)", g_nrows); + else + snprintf(foot, sizeof foot, "%d row(s)", g_nrows); + app_draw_footer(r_content, foot); +} + +static int viewer_hit(meow_widget *w, int x, int y, void *ctx) +{ + (void)w; (void)ctx; + return x >= r_content.x && x < r_content.x + r_content.w && + y >= r_content.y && y < r_content.y + r_content.h; +} + +static void viewer_clamp_scroll(void) +{ + const meow_theme *th = meow_theme_get(); + int rowh = viewer_rowh(th); + int content_h = g_nrows * rowh; + int maxs = content_h - viewer_vp_h(); + if (maxs < 0) maxs = 0; + if (g_vscroll < 0) g_vscroll = 0; + if (g_vscroll > maxs) g_vscroll = maxs; +} + +int viewer_open_file(const char *path) +{ + if (viewer_build(path) != 0) return -1; + meow_view_page_push(ACT_VIEWER); + return 0; +} + +int viewer_is_active(void) { return g_vc.active; } + +void viewer_swipe(int dir, int dist) +{ + if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return; + int del = (dir == MEOW_SWIPE_UP) ? dist : -dist; + g_vscroll += del; + viewer_clamp_scroll(); + meow_ui_invalidate(r_content.x, r_content.y, r_content.w, r_content.h); +} + +void viewer_touch_down(int x, int y) +{ + (void)x; + g_vdrag = 0; g_vdrag_last_y = 0; + if (!g_buf) return; + if (meow_widget_hit(g_vcw, x, y)) { g_vdrag = 1; g_vdrag_last_y = y; } +} + +void viewer_touch_move(int x, int y) +{ + (void)x; + if (!g_vdrag) return; + int dy = y - g_vdrag_last_y; + g_vdrag_last_y = y; + if (dy == 0) return; + g_vscroll -= dy; + viewer_clamp_scroll(); + meow_ui_invalidate(r_content.x, r_content.y, r_content.w, r_content.h); +} + +int viewer_on_tap(int x, int y) +{ + if (g_tb) { + int ah = meow_topappbar_hit(g_tb, x, y); + if (ah == MEOW_TOPBAR_BACK) { meow_view_page_pop(); return 1; } + } + return 0; +} + +/* ---- 页 view 生命周期 ---- */ + +static void viewer_page_create(meow_view *v, void *ctx) +{ + (void)v; + ((viewer_ctrl *)ctx)->view = v; + r_content = app_content_rect(); + meow_ui_activity_push(ACT_VIEWER); /* 让下面控件 owner=ACT_VIEWER */ + g_tb = app_topbar_create("Preview", 1, NULL); + g_vcw = meow_widget_custom_create(r_content.x, r_content.y, r_content.w, r_content.h, + viewer_render, viewer_hit, NULL); + meow_ui_activity_pop(); +} + +static void viewer_page_enter(meow_view *v, void *ctx) +{ + (void)v; + ((viewer_ctrl *)ctx)->active = 1; + g_vscroll = 0; + if (g_tb) meow_topappbar_set_title(g_tb, g_fname[0] ? g_fname : "Preview"); + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + meow_ui_paint_full(); +} + +static void viewer_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((viewer_ctrl *)ctx)->active = 0; +} + +static void viewer_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + if (g_vcw) { meow_widget_free(g_vcw); g_vcw = NULL; } + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } + free(g_buf); free(g_line); free(g_rown); + g_buf = NULL; g_line = NULL; g_rown = NULL; +} + +meow_view *viewer_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = viewer_page_create, + .on_enter = viewer_page_enter, + .on_leave = viewer_page_leave, + .on_destroy = viewer_page_destroy, + }; + return meow_view_create(ACT_VIEWER, MEOW_VIEW_ACTIVITY, &h, &g_vc); +}