/** **************************************************************************************************** * @file ui_files.c * @author HiFi Player Project * @version V1.0 * @date 2026-01-01 * @brief 文件管理器 —— 浏览 SD 卡上的全部文件,支持进出子文件夹 * * ┌──────────────────────────────────┐ * │ 📁 FILES 12 SD │ 状态栏 * ├──────────────────────────────────┤ * │ 0:/MUSIC/Album │ 当前路径 * ├──────────────────────────────────┤ * │ 📁 .. │ 选中行(高亮 + 描边) * │ 📁 Album │ * │ ♪ song01.flac 24.1M │ 目录在前,文件在后,按名称排序 * │ 📄 notes.txt 128B │ * │ 📄 readme.md 2K │ * ├──────────────────────────────────┤ * │ [◀K0] [▲K1] [▼K2] [✔K3] │ * │ BACK UP DOWN OPEN │ * └──────────────────────────────────┘ * * 实现要点: * 1. 不重建控件。界面创建时固定做 FILES_ROWS 个“行”,刷新时把 * [s_top, s_top+FILES_ROWS) 的条目映射到这些行上(滑动窗口), * 即使目录里有几百个文件也不会撑爆 LVGL 内存。 * 2. 目录里超过 FILES_MAX_ENTRIES 的条目会被截断,避免静态数组过大。 * 3. 非根目录时列表第一行固定是 "..",回车即可返回上级; * K0 短按同样用于返回上级(根目录时由 ui.c 接管回主界面)。 * 4. 中文文件名是 GBK 编码,LVGL 内置字体无法显示,用 '?' 逐字占位, * 但进入目录 / 打开文件仍使用原始字节,不影响功能。 **************************************************************************************************** */ #include "ui_files.h" #include #include #include #include #include #include "esp_log.h" #include "ff.h" #include "spi_sd.h" #include "ui_common.h" #include "app.h" #define TAG "ui_files" /* ---------------- 浏览参数 ---------------- */ #define FILES_ROOT "0:" /* SD 卡挂载点下的根目录 */ #define FILES_MAX_ENTRIES 96 /* 单目录最多显示多少条 */ #define FILES_NAME_MAX 96 /* 单条文件名缓冲 */ #define FILES_ROWS 5 /* 列表可见行数 */ #define FILES_ROW_H 28 /* 每行高度 */ #define FILES_PATH_MAX 300 /* 当前路径缓冲 */ /* ---------------- 屏幕布局 ---------------- */ /* 注意:不能用 LIST_H 这类名字,FreeRTOS 的 list.h 用 LIST_H 作为 include guard */ #define FILES_HEADER_H 30 #define FILES_PATH_Y 32 #define FILES_PATH_H 20 #define FILES_LIST_Y 54 #define FILES_LIST_H (FILES_ROWS * FILES_ROW_H) /* 140 → 底边 194 */ #define FILES_TOAST_Y 150 /* 每行内部的横向布局 */ #define ROW_NAME_X 22 /* 名称相对行左边的偏移 */ #define ROW_NAME_W 224 /* 名称最大宽度(超出滚动) */ #define ROW_SIZE_W 44 /* 右侧大小 / 宽度 */ /* FATFS 卷标属性(本版本 ff.h 未提供 AM_VOL) */ #define FILES_AM_VOL 0x08 /* ---------------- 条目 ---------------- */ typedef struct { char name[FILES_NAME_MAX]; /* 原始文件 / 目录名(GBK 原样保留) */ uint32_t size; /* 文件字节数,目录为 0 */ bool is_dir; } files_entry_t; /* ---------------- 行控件(固定池) ---------------- */ typedef struct { lv_obj_t *row; lv_obj_t *icon; lv_obj_t *name; lv_obj_t *size; } files_row_t; /* ---------------- 界面控件 ---------------- */ static lv_obj_t *s_scr = NULL; static lv_obj_t *s_lbl_path = NULL; static lv_obj_t *s_lbl_count = NULL; static lv_obj_t *s_lbl_empty = NULL; static lv_obj_t *s_toast = NULL; static lv_obj_t *s_toast_lbl = NULL; static files_row_t s_rows[FILES_ROWS]; static ui_key_bar_t s_bar; static ui_flash_t s_flash; /* ---------------- 浏览状态 ---------------- */ static files_entry_t s_entries[FILES_MAX_ENTRIES]; static int s_count = 0; /* 有效条目数 */ static int s_sel = 0; /* 选中项下标 */ static int s_top = 0; /* 列表窗口首行对应的条目下标 */ static char s_path[FILES_PATH_MAX] = FILES_ROOT; static FRESULT s_load_res = FR_OK; /* 最近一次目录读取结果 */ static bool s_sd_ok = false; /* SD 卡是否已可读 */ static bool s_mount_tried = false; /* 是否已经尝试过挂载 */ static uint32_t s_toast_tick = 0; /* ---------------- 按键提示 ---------------- */ static const char *const s_symbols[APP_KEY_NUM] = { LV_SYMBOL_LEFT, /* K0:返回上级目录 / 回主界面 */ LV_SYMBOL_UP, /* K1:上一项 */ LV_SYMBOL_DOWN, /* K2:下一项 */ LV_SYMBOL_OK, /* K3:进入目录 */ }; static const char *const s_captions[APP_KEY_NUM] = { "K0 BACK", "K1 UP", "K2 DOWN", "K3 OPEN", }; static const bool s_enabled[APP_KEY_NUM] = { true, true, true, true }; /* ================================================================================================ * 小工具 * ============================================================================================== */ /** * @brief GBK 文件名 → 可在 LVGL 内置字体上显示的字符串 * @note ASCII 原样保留;每个双字节汉字折叠成一个 '?',避免出现乱码方块 */ static void files_display_name(const char *src, char *dst, size_t size) { size_t len = strlen(src); size_t j = 0; if (size == 0) { return; } for (size_t i = 0; (i < len) && (j + 1 < size); i++) { unsigned char c = (unsigned char)src[i]; if ((c >= 0x20) && (c < 0x7F)) { dst[j++] = (char)c; continue; } /* GBK 双字节字符:跳过第二个字节,只输出一个占位符 */ if ((c >= 0x81) && (i + 1 < len)) { unsigned char t = (unsigned char)src[i + 1]; if ((t >= 0x40) && (t <= 0xFE)) { i++; } } dst[j++] = '?'; } dst[j] = '\0'; } /** 文件大小 → "128B" / "2K" / "24.1M"(纯整数运算,不依赖浮点 printf) */ static void files_format_size(uint32_t bytes, char *out, size_t size) { const uint32_t mb = 1024u * 1024u; if (bytes >= mb) { uint32_t whole = bytes / mb; uint32_t frac = ((bytes % mb) * 10u) / mb; snprintf(out, size, "%u.%uM", (unsigned)whole, (unsigned)frac); } else if (bytes >= 1024u) { snprintf(out, size, "%uK", (unsigned)(bytes / 1024u)); } else { snprintf(out, size, "%uB", (unsigned)bytes); } } /** 冒烟测试用的扩展名判断(只影响图标和颜色) */ static bool files_name_has_ext(const char *name, const char *ext) { size_t nlen = strlen(name); size_t elen = strlen(ext); if (nlen <= elen) { return false; } const char *tail = name + nlen - elen; for (size_t i = 0; i < elen; i++) { char a = tail[i]; char b = ext[i]; if ((a >= 'A') && (a <= 'Z')) { a = (char)(a - 'A' + 'a'); } if ((b >= 'A') && (b <= 'Z')) { b = (char)(b - 'A' + 'a'); } if (a != b) { return false; } } return true; } static bool files_is_audio(const char *name) { return files_name_has_ext(name, ".wav") || files_name_has_ext(name, ".flac"); } static bool files_is_image(const char *name) { return files_name_has_ext(name, ".bmp") || files_name_has_ext(name, ".png") || files_name_has_ext(name, ".jpg") || files_name_has_ext(name, ".jpeg") || files_name_has_ext(name, ".gif"); } /* ================================================================================================ * SD 卡 * ============================================================================================== */ /** * @brief 确保 SD 卡已挂载 * @note 正常情况下播放器任务在开机时已经挂载好,这里只做“探测”, * 只有探测失败且从未尝试过时才主动挂载一次,避免和播放器争抢。 */ static void files_ensure_mounted(void) { FF_DIR dir; if (f_opendir(&dir, FILES_ROOT) == FR_OK) { f_closedir(&dir); if (!s_sd_ok) { s_sd_ok = true; ESP_LOGI(TAG, "sd card ready"); } return; } if (s_mount_tried) { return; /* 只主动尝试一次 */ } s_mount_tried = true; if (sd_spi_init() == ESP_OK) { s_sd_ok = true; ESP_LOGI(TAG, "sd card mounted by files app"); } else { ESP_LOGW(TAG, "sd card not available"); } } /* ================================================================================================ * 目录读取 * ============================================================================================== */ /** qsort 比较:`..` → 目录 → 文件,同类按名称不区分大小写 */ static int files_cmp(const void *a, const void *b) { const files_entry_t *x = (const files_entry_t *)a; const files_entry_t *y = (const files_entry_t *)b; bool x_up = (strcmp(x->name, "..") == 0); bool y_up = (strcmp(y->name, "..") == 0); if (x_up != y_up) { return x_up ? -1 : 1; } if (x->is_dir != y->is_dir) { return x->is_dir ? -1 : 1; } return strcasecmp(x->name, y->name); } /** * @brief 读取当前路径下的条目到 s_entries * @retval 有效条目数;s_load_res 给出 FatFs 的原始结果 */ static int files_load(void) { FF_DIR dir; FILINFO *fi = NULL; FRESULT res = FR_OK; int n = 0; /* 非根目录:第一行固定插入 "..",回车即返回上级 */ if (strcmp(s_path, FILES_ROOT) != 0) { snprintf(s_entries[0].name, sizeof(s_entries[0].name), ".."); s_entries[0].is_dir = true; s_entries[0].size = 0; n = 1; } fi = (FILINFO *)malloc(sizeof(FILINFO)); if (fi == NULL) { ESP_LOGE(TAG, "no memory for FILINFO"); s_load_res = FR_NOT_ENOUGH_CORE; return n; } res = f_opendir(&dir, s_path); if (res == FR_OK) { while (n < FILES_MAX_ENTRIES) { res = f_readdir(&dir, fi); if (res != FR_OK) { break; } if (fi->fname[0] == '\0') { break; /* 目录遍历结束 */ } /* 隐藏 / 系统 / 卷标 一律不显示 */ if ((fi->fattrib & AM_HID) || (fi->fattrib & AM_SYS) || (fi->fattrib & FILES_AM_VOL)) { continue; } if (strcmp(fi->fname, ".") == 0) { continue; } /* strlcpy 不做格式化,可以避开 -Wformat-truncation; 超过 FILES_NAME_MAX-1 的极端长名会被截断(仅此类目录无法再进入) */ strlcpy(s_entries[n].name, fi->fname, sizeof(s_entries[n].name)); s_entries[n].is_dir = (fi->fattrib & AM_DIR) != 0; s_entries[n].size = (uint32_t)fi->fsize; n++; } f_closedir(&dir); } free(fi); s_load_res = res; if (n > 1) { qsort(s_entries, (size_t)n, sizeof(files_entry_t), files_cmp); } return n; } /* ================================================================================================ * 界面刷新 * ============================================================================================== */ /** 让选中项始终落在可见窗口内 */ static void files_keep_visible(void) { if (s_sel < s_top) { s_top = s_sel; } else if (s_sel >= (s_top + FILES_ROWS)) { s_top = s_sel - FILES_ROWS + 1; } int max_top = (s_count > FILES_ROWS) ? (s_count - FILES_ROWS) : 0; if (s_top > max_top) { s_top = max_top; } if (s_top < 0) { s_top = 0; } } /** K0 的含义随深度变化:根目录是“回主界面”,子目录是“返回上级” */ static void files_update_keybar(void) { bool at_root = (strcmp(s_path, FILES_ROOT) == 0); ui_key_bar_set_caption(&s_bar, APP_KEY_0, at_root ? "K0 HOME" : "K0 UP"); } static void files_hide_toast(void) { lv_obj_add_flag(s_toast, LV_OBJ_FLAG_HIDDEN); } static void files_show_toast(const char *text) { ui_label_set_if_changed(s_toast_lbl, text); lv_obj_clear_flag(s_toast, LV_OBJ_FLAG_HIDDEN); s_toast_tick = lv_tick_get(); } /** 把当前窗口内的条目画到固定的行控件上 */ static void files_refresh(void) { char buf[80]; /* ---- 顶部:路径 + 条目数 ---- */ ui_label_set_if_changed(s_lbl_path, s_path); snprintf(buf, sizeof(buf), "%d", s_count); ui_label_set_if_changed(s_lbl_count, buf); /* ---- 空目录 / 读取失败提示 ---- */ if (s_count == 0) { if (s_load_res == FR_OK) { ui_label_set_if_changed(s_lbl_empty, "EMPTY FOLDER"); } else if (s_sd_ok) { snprintf(buf, sizeof(buf), "CANNOT READ (%d)", (int)s_load_res); ui_label_set_if_changed(s_lbl_empty, buf); } else { ui_label_set_if_changed(s_lbl_empty, "NO SD CARD"); } lv_obj_clear_flag(s_lbl_empty, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(s_lbl_empty, LV_OBJ_FLAG_HIDDEN); } /* ---- 列表行 ---- */ for (int i = 0; i < FILES_ROWS; i++) { files_row_t *r = &s_rows[i]; int idx = s_top + i; if (idx >= s_count) { lv_obj_add_flag(r->row, LV_OBJ_FLAG_HIDDEN); continue; } lv_obj_clear_flag(r->row, LV_OBJ_FLAG_HIDDEN); const files_entry_t *e = &s_entries[idx]; bool sel = (idx == s_sel); /* 选中:深色底 + 强调色描边 */ lv_obj_set_style_bg_color(r->row, lv_color_hex(sel ? UI_COL_CARD_SEL : UI_COL_CARD), LV_PART_MAIN); lv_obj_set_style_border_width(r->row, sel ? 1 : 0, LV_PART_MAIN); /* 图标:目录黄色、音频青色、图片紫色、其它灰色 */ const char *icon = LV_SYMBOL_FILE; uint32_t icon_col = UI_COL_DIM; if (e->is_dir) { icon = LV_SYMBOL_DIRECTORY; icon_col = UI_COL_WARN; } else if (files_is_audio(e->name)) { icon = LV_SYMBOL_AUDIO; icon_col = UI_COL_ACCENT; } else if (files_is_image(e->name)) { icon = LV_SYMBOL_IMAGE; icon_col = UI_COL_ACCENT2; } ui_label_set_if_changed(r->icon, icon); lv_obj_set_style_text_color(r->icon, lv_color_hex(icon_col), LV_PART_MAIN); /* 名称(非 ASCII 折叠成 '?') */ files_display_name(e->name, buf, sizeof(buf)); ui_label_set_if_changed(r->name, buf); lv_obj_set_style_text_color(r->name, lv_color_hex(sel ? UI_COL_TEXT : 0xD5DBEA), LV_PART_MAIN); /* 右侧:目录显示 ,文件显示大小 */ if (e->is_dir) { ui_label_set_if_changed(r->size, ""); } else { files_format_size(e->size, buf, sizeof(buf)); ui_label_set_if_changed(r->size, buf); } } files_update_keybar(); } /* ================================================================================================ * 导航 * ============================================================================================== */ /** * @brief 打开一个目录(失败则退回原目录) */ static void files_open_dir(const char *path) { char target[FILES_PATH_MAX]; char backup[FILES_PATH_MAX]; int old_sel = s_sel; int n; /* 先把目标路径复制出来,避免 path 指向 s_path 自身时 snprintf 重叠 */ strlcpy(target, path, sizeof(target)); snprintf(backup, sizeof(backup), "%s", s_path); strlcpy(s_path, target, sizeof(s_path)); files_ensure_mounted(); n = files_load(); if ((n == 0) && (s_load_res != FR_OK) && (strcmp(target, FILES_ROOT) != 0)) { ESP_LOGW(TAG, "cannot open %s (%d)", target, (int)s_load_res); strlcpy(s_path, backup, sizeof(s_path)); n = files_load(); s_sel = old_sel; } else { s_sel = 0; } s_count = n; s_top = 0; files_keep_visible(); files_refresh(); } /** * @brief 返回上一级目录 * @retval true 已返回;false 已在根目录 */ static bool files_go_up(void) { if (strcmp(s_path, FILES_ROOT) == 0) { return false; } char parent[FILES_PATH_MAX]; char *slash = strrchr(s_path, '/'); if (slash == NULL) { return false; } size_t len = (size_t)(slash - s_path); if (len == 0) { snprintf(parent, sizeof(parent), "%s", FILES_ROOT); } else { if (len >= sizeof(parent)) { len = sizeof(parent) - 1; } memcpy(parent, s_path, len); parent[len] = '\0'; } files_open_dir(parent); return true; } /** * @brief 打开选中项:目录则进入,文件则弹出详情 */ static void files_enter(void) { if ((s_count <= 0) || (s_sel < 0) || (s_sel >= s_count)) { return; } const files_entry_t *e = &s_entries[s_sel]; if (e->is_dir) { if (strcmp(e->name, "..") == 0) { files_go_up(); return; } char next[FILES_PATH_MAX]; size_t plen = strlen(s_path); size_t nlen = strlen(e->name); /* 拼成 "<当前路径>/<子目录名>"(s_path 根目录时是 "0:",同样需要斜杠) */ if ((plen + 1u + nlen + 1u) > sizeof(next)) { files_show_toast("PATH TOO LONG"); return; } memcpy(next, s_path, plen); next[plen] = '/'; memcpy(next + plen + 1u, e->name, nlen); next[plen + 1u + nlen] = '\0'; files_open_dir(next); return; } /* 普通文件:显示文件名 + 大小 + 类型 */ char disp[80]; char size_text[24]; char text[128]; files_display_name(e->name, disp, sizeof(disp)); files_format_size(e->size, size_text, sizeof(size_text)); snprintf(text, sizeof(text), "%s\n%s %s", disp, size_text, files_is_audio(e->name) ? "AUDIO" : (files_is_image(e->name) ? "IMAGE" : "FILE")); files_show_toast(text); } /* ================================================================================================ * 对外接口 * ============================================================================================== */ /** 创建一行(固定池,不随目录内容变化) */ static void files_create_row(lv_obj_t *parent, int i) { files_row_t *r = &s_rows[i]; r->row = ui_card(parent, 0, (lv_coord_t)(i * FILES_ROW_H), UI_HOR_RES - 16, FILES_ROW_H - 2, UI_COL_CARD, 7); lv_obj_set_style_pad_hor(r->row, 6, LV_PART_MAIN); lv_obj_set_style_border_width(r->row, 0, LV_PART_MAIN); lv_obj_set_style_border_color(r->row, lv_color_hex(UI_COL_ACCENT), LV_PART_MAIN); r->icon = ui_label(r->row, LV_SYMBOL_FILE, &lv_font_montserrat_14, UI_COL_DIM); lv_obj_set_width(r->icon, 18); lv_obj_align(r->icon, LV_ALIGN_LEFT_MID, 0, 0); r->size = ui_label(r->row, "", &lv_font_montserrat_12, UI_COL_SUB); lv_obj_set_width(r->size, ROW_SIZE_W); lv_obj_set_style_text_align(r->size, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN); lv_obj_align(r->size, LV_ALIGN_RIGHT_MID, 0, 0); r->name = ui_label(r->row, "", &lv_font_montserrat_14, UI_COL_TEXT); lv_obj_set_size(r->name, ROW_NAME_W, FILES_ROW_H - 4); lv_label_set_long_mode(r->name, LV_LABEL_LONG_SCROLL_CIRCULAR); lv_obj_align(r->name, LV_ALIGN_LEFT_MID, ROW_NAME_X, 0); } void ui_files_create(void) { s_scr = lv_obj_create(NULL); ui_style_screen(s_scr); /* ---------------- 标题栏 ---------------- */ lv_obj_t *header = ui_card(s_scr, 0, 0, UI_HOR_RES, FILES_HEADER_H, UI_COL_PANEL, 0); lv_obj_t *title = ui_label(header, LV_SYMBOL_DIRECTORY " FILES", &lv_font_montserrat_16, 0x31C27C); lv_obj_align(title, LV_ALIGN_LEFT_MID, 10, 0); lv_obj_t *hint = ui_label(header, "SD", &lv_font_montserrat_12, UI_COL_SUB); lv_obj_align(hint, LV_ALIGN_RIGHT_MID, -44, 0); s_lbl_count = ui_label(header, "0", &lv_font_montserrat_12, UI_COL_ACCENT); lv_obj_align(s_lbl_count, LV_ALIGN_RIGHT_MID, -10, 0); /* ---------------- 当前路径 ---------------- */ lv_obj_t *path_bar = ui_card(s_scr, 8, FILES_PATH_Y, UI_HOR_RES - 16, FILES_PATH_H, UI_COL_TRACK, 6); s_lbl_path = ui_label(path_bar, FILES_ROOT, &lv_font_montserrat_12, UI_COL_ACCENT); lv_obj_set_width(s_lbl_path, UI_HOR_RES - 16 - 12); lv_label_set_long_mode(s_lbl_path, LV_LABEL_LONG_SCROLL_CIRCULAR); lv_obj_align(s_lbl_path, LV_ALIGN_LEFT_MID, 6, 0); /* ---------------- 列表容器(不支持滚轮,靠滑动窗口) ---------------- */ lv_obj_t *list = lv_obj_create(s_scr); lv_obj_set_pos(list, 8, FILES_LIST_Y); lv_obj_set_size(list, UI_HOR_RES - 16, FILES_LIST_H); lv_obj_clear_flag(list, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(list, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); for (int i = 0; i < FILES_ROWS; i++) { files_create_row(list, i); } /* ---------------- 空目录 / 错误提示 ---------------- */ s_lbl_empty = ui_label(s_scr, "NO SD CARD", &lv_font_montserrat_14, UI_COL_DIM); lv_obj_align(s_lbl_empty, LV_ALIGN_TOP_MID, 0, 116); /* ---------------- 文件详情浮层(创建在最后,压在最上层) ---------------- */ s_toast = ui_card(s_scr, 8, FILES_TOAST_Y, UI_HOR_RES - 16, 38, UI_COL_PANEL, 8); lv_obj_set_style_border_width(s_toast, 1, LV_PART_MAIN); lv_obj_set_style_border_color(s_toast, lv_color_hex(UI_COL_ACCENT), LV_PART_MAIN); lv_obj_add_flag(s_toast, LV_OBJ_FLAG_HIDDEN); s_toast_lbl = ui_label(s_toast, "", &lv_font_montserrat_12, UI_COL_TEXT); lv_obj_set_width(s_toast_lbl, UI_HOR_RES - 16 - 16); lv_obj_set_style_text_align(s_toast_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_center(s_toast_lbl); /* ---------------- 底部按键提示 ---------------- */ ui_key_bar_create(s_scr, &s_bar, s_symbols, s_captions, s_enabled); } void ui_files_show(void) { ui_key_bar_set_highlight(&s_bar, -1); files_hide_toast(); /* 每次进入应用都从根目录重新开始,行为可预测 */ files_open_dir(FILES_ROOT); lv_scr_load(s_scr); } void ui_files_tick(void) { /* 文件详情浮层 2 秒后自动消失 */ if (!lv_obj_has_flag(s_toast, LV_OBJ_FLAG_HIDDEN) && (lv_tick_elaps(s_toast_tick) > 2000)) { files_hide_toast(); } ui_key_bar_set_highlight(&s_bar, ui_flash_active(&s_flash)); } void ui_files_flash(int key) { ui_flash_trigger(&s_flash, key); } void ui_files_handle_key(uint8_t key) { ui_flash_trigger(&s_flash, key); switch (key) { case APP_KEY_1: /* 上一项(到顶后跳到底部) */ if (s_count > 0) { s_sel = (s_sel <= 0) ? (s_count - 1) : (s_sel - 1); files_keep_visible(); files_refresh(); } break; case APP_KEY_2: /* 下一项 */ if (s_count > 0) { s_sel = (s_sel + 1) % s_count; files_keep_visible(); files_refresh(); } break; case APP_KEY_3: /* 进入目录 / 查看文件 */ files_enter(); break; default: break; } } bool ui_files_back(void) { if (strcmp(s_path, FILES_ROOT) == 0) { return false; /* 已在根目录:交给 ui.c 回主界面 */ } return files_go_up(); }