feat(files): 新增外置系统文件管理器 os.meow.files (agent-183) — 目录浏览(虚拟化列表/空态)+文本预览(≤256KB截断)+删除/重命名/新建/属性(BottomSheet+IME);路径写操作限 TF 卡内(拒 .. 越界);多文件结构(app.c+ui/{browser,viewer}.c+ui/common/chrome.c+logic/fsx.c) 按批次②范本

files md5 6f6b0a6d; 0 warning; 宿主 test_fsx 全过(路径安全/格式化/排序/虚拟化/写操作/文本嗅探); qemu 离屏冒烟通过
SDK 缺口记录: listview 仅单行 label(本 App 用 custom 画布自绘行); common 档字库中文缺字(未声明 fonts:full)
真机待验证: 列表渲染/滚动手感/IME 重命名/弹窗交互
This commit is contained in:
MeowOS committed 2026-08-31 04:03:56 +08:00
1 parent b5038f8673
commit c187b7bce3
12 files changed
+1741

No files matched your search

+504
View File
@@ -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 <errno.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
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);
}