#include "frontends/tui/tui.hpp" #include "frontends/tui/tui_helpers.hpp" #include #include #include #include #include #include #include using namespace ftxui; namespace fs = std::filesystem; // Free helpers defined in shell.cpp. void SaveLastUsed(const std::string &key, const std::string &dir, const std::string &filename); bool LoadLastUsed(const std::string &key, std::string &dir, std::string &filename); namespace { // Return the lowercased extension (with leading dot) of `name`, or empty. std::string ext_of(const std::string &name) { size_t dot = name.rfind('.'); if (dot == std::string::npos) return {}; std::string e = name.substr(dot); for (char &c : e) c = (char)std::tolower((unsigned char)c); return e; } // Replace the trailing extension of `name` with `new_ext` (which already // includes the dot). If no dot is present, append it. void replace_ext(std::string &name, const std::string &new_ext) { size_t dot = name.rfind('.'); if (dot == std::string::npos) { name += new_ext; return; } name = name.substr(0, dot) + new_ext; } // Index of the filter whose extension matches `e`, or -1. int filter_for_ext(const std::vector &filters, const std::string &e) { for (int i = 0; i < (int)filters.size(); ++i) if (filters[i].extension == e) return i; return -1; } } // namespace void Tui::OpenFileDialog(std::string title, std::string persist_key, std::string default_filename, std::vector filters, std::function on_confirm, bool confirm_overwrite) { file_dialog.title = std::move(title); file_dialog.persist_key = std::move(persist_key); file_dialog.on_confirm = std::move(on_confirm); file_dialog.filters = std::move(filters); file_dialog.confirm_overwrite = confirm_overwrite; file_dialog.filter_labels.clear(); for (const auto &f : file_dialog.filters) file_dialog.filter_labels.push_back(f.label); file_dialog.status.clear(); std::string saved_dir, saved_fn; if (LoadLastUsed(file_dialog.persist_key, saved_dir, saved_fn)) { file_dialog.dir = std::move(saved_dir); file_dialog.filename = std::move(saved_fn); } else { std::error_code ec; fs::path cwd = fs::current_path(ec); file_dialog.dir = ec ? std::string(".") : cwd.string(); file_dialog.filename = std::move(default_filename); } // Sync filter_idx to the current filename extension if one matches. file_dialog.filter_idx = 0; if (!file_dialog.filters.empty()) { int i = filter_for_ext(file_dialog.filters, ext_of(file_dialog.filename)); if (i >= 0) file_dialog.filter_idx = i; } file_dialog.entry_idx = 0; // Default focus: filename input (Container child 2). file_dialog.focus_idx = 2; file_dialog.open = true; } void Tui::ConfirmFileDialog() { if (file_dialog.filename.empty()) { file_dialog.status = "filename is empty"; return; } // When the caller provided a filter list, treat it as a whitelist of // acceptable extensions. An unrecognised extension keeps the dialog // open with an explanatory status — never silently fall through to a // wrong-format write. if (!file_dialog.filters.empty()) { std::string e = ext_of(file_dialog.filename); if (filter_for_ext(file_dialog.filters, e) < 0) { std::string allowed; for (size_t i = 0; i < file_dialog.filters.size(); ++i) { if (i) allowed += ", "; allowed += file_dialog.filters[i].extension; } file_dialog.status = "unknown extension '" + (e.empty() ? std::string("(none)") : e) + "' — accepted: " + allowed; return; } } fs::path full = fs::path(file_dialog.dir) / file_dialog.filename; auto cb = file_dialog.on_confirm; SaveLastUsed(file_dialog.persist_key, file_dialog.dir, file_dialog.filename); file_dialog.open = false; auto invoke = [cb, full]() { if (cb) cb(full.string()); }; // Overwrite guard: if the file already exists, ask before letting // the action proceed. Esc / No cancels; Yes runs the action. std::error_code ec; if (file_dialog.confirm_overwrite && fs::exists(full, ec) && !ec) { ShowConfirm("File '" + full.string() + "' already exists.\n" "Overwrite?", invoke); return; } invoke(); } Component Tui::BuildFileDialog() { InputOption fn_opt; fn_opt.multiline = false; fn_opt.transform = [](InputState s) { auto el = s.element; if (s.is_placeholder) el |= dim; return el; }; auto filename_input = Input(&file_dialog.filename, "filename…", fn_opt); MenuOption entries_opt = MenuOption::Vertical(); entries_opt.entries = &file_dialog.entries; entries_opt.selected = &file_dialog.entry_idx; entries_opt.focused_entry = &file_dialog.entry_idx; entries_opt.on_enter = [this]() { if (file_dialog.entry_idx < 0 || file_dialog.entry_idx >= (int)file_dialog.entries.size()) return; const std::string &entry = file_dialog.entries[file_dialog.entry_idx]; if (!file_dialog.entries_is_dir[file_dialog.entry_idx]) { file_dialog.filename = entry; // sync filter to the picked file's extension if (!file_dialog.filters.empty()) { int i = filter_for_ext(file_dialog.filters, ext_of(file_dialog.filename)); if (i >= 0) file_dialog.filter_idx = i; } file_dialog.focus_idx = 3; // jump to the OK button return; } std::error_code ec; fs::path next; if (entry == "../") next = fs::path(file_dialog.dir).parent_path(); else next = fs::path(file_dialog.dir) / entry; fs::path canon = fs::canonical(next, ec); if (ec) return; file_dialog.dir = canon.string(); file_dialog.entry_idx = 0; }; auto entries_menu = Menu(entries_opt); // Horizontal filter selector (Toggle). The sync between the picked // filter and the filename extension is done inside the Renderer // lambda below (FTXUI's plain Toggle API has no on_change hook). auto filter_toggle = Toggle(&file_dialog.filter_labels, &file_dialog.filter_idx); // Transparent label when idle, inverted (reverse-video) when focused // — clearly visible without painting a colour into the background. ButtonOption btn_opt; btn_opt.transform = [](const EntryState &s) { Element el = text(s.label); if (s.focused) el = el | inverted; return el; }; auto button = Button(" OK ", [this]() { ConfirmFileDialog(); }, btn_opt); auto container = Container::Vertical( {filter_toggle, entries_menu, filename_input, button}, &file_dialog.focus_idx); auto handler = CatchEvent(container, [this](Event e) { if (e == Event::Escape) { file_dialog.open = false; return true; } // Container has 4 children at indices 0..3: filter_toggle, // entries_menu, filename_input, button. `focus_idx` indexes // them directly. When the dialog has no filters, slot 0 is // unused (the toggle isn't rendered) and Tab must skip it. int base = file_dialog.filters.empty() ? 1 : 0; if (e == Event::Tab) { do { file_dialog.focus_idx = (file_dialog.focus_idx + 1) % 4; } while (file_dialog.focus_idx < base); return true; } if (e == Event::TabReverse) { do { file_dialog.focus_idx = (file_dialog.focus_idx + 3) % 4; } while (file_dialog.focus_idx < base); return true; } return false; }); return Renderer(handler, [this, entries_menu, filename_input, button, filter_toggle, last_filter_idx = int(-1)]() mutable { // Sync filename extension to the currently picked filter when it // changes (since FTXUI Toggle has no on_change hook). if (!file_dialog.filters.empty() && file_dialog.filter_idx != last_filter_idx && file_dialog.filter_idx >= 0 && file_dialog.filter_idx < (int)file_dialog.filters.size()) { // On the first frame after Open, just record the index — don't // rewrite the user's chosen filename. From then on, every // change rewrites the extension. if (last_filter_idx >= 0) { replace_ext(file_dialog.filename, file_dialog.filters[file_dialog.filter_idx].extension); } last_filter_idx = file_dialog.filter_idx; } file_dialog.entries.clear(); file_dialog.entries_is_dir.clear(); file_dialog.entries.push_back("../"); file_dialog.entries_is_dir.push_back(true); try { std::vector> raw; for (const auto &e : fs::directory_iterator(file_dialog.dir)) { std::string n = e.path().filename().string(); bool is_dir = e.is_directory(); raw.emplace_back(is_dir ? n + "/" : n, is_dir); } std::sort(raw.begin(), raw.end(), [](const auto &a, const auto &b) { if (a.second != b.second) return a.second; return NaturalLess(a.first, b.first); }); for (auto &p : raw) { file_dialog.entries.push_back(std::move(p.first)); file_dialog.entries_is_dir.push_back(p.second); } } catch (const std::exception &) {} if (file_dialog.entry_idx >= (int)file_dialog.entries.size()) file_dialog.entry_idx = (int)file_dialog.entries.size() - 1; if (file_dialog.entry_idx < 0) file_dialog.entry_idx = 0; Element status = file_dialog.status.empty() ? text("") | dim : text(" " + file_dialog.status + " ") | bold; Elements rows; rows.push_back(text(" " + file_dialog.title + " ") | bold | center); rows.push_back(separator()); if (!file_dialog.filters.empty()) { rows.push_back(hbox({ FocusLabel(text(" format: "), file_dialog.focus_idx == 0) | bold, filter_toggle->Render(), })); rows.push_back(separator()); } rows.push_back(hbox({text(" dir: ") | dim, text(file_dialog.dir)})); rows.push_back(separator()); // Focus index maps directly to the Container::Vertical child // index — 1 = entries, 2 = filename, 3 = button (0 = filter // toggle, only meaningful when filters are present). rows.push_back(FocusLabel(text(" entries "), file_dialog.focus_idx == 1) | bold); rows.push_back(entries_menu->Render() | vscroll_indicator | yframe | size(HEIGHT, LESS_THAN, 16) | size(HEIGHT, GREATER_THAN, 6)); rows.push_back(separator()); rows.push_back(hbox({ FocusLabel(text(" filename: "), file_dialog.focus_idx == 2), filename_input->Render() | flex, }) | border); rows.push_back(separator()); // The button's own ButtonOption::transform already inverts the // label when focused — no outer FocusLabel here (it would // double-invert and cancel out). rows.push_back(hbox({filler(), button->Render(), filler()})); rows.push_back(status); rows.push_back(text(" Tab cycle • Enter: cd / pick / confirm • Esc cancel ") | dim); return vbox(std::move(rows)) | borderRounded | size(WIDTH, LESS_THAN, 80) | size(WIDTH, GREATER_THAN, 50); }); }