Reorganise the tree into business vs frontend as separate directories:
src/core/{domain,imports,app} (was system/, imports/, app/)
src/frontends/tui/ (was tui/ + main.cpp)
tests/tui/ (the FTXUI-coupled helper test)
All cross-dir #include paths rewritten; same-dir includes untouched.
CMake: essim_core is the frontend-agnostic business library — links libzip,
pugixml and bsdl, NO GUI toolkit. Each frontend is a self-contained
src/frontends/<name>/ (own CMakeLists, toolkit, main.cpp) that links
essim_core, selected with -DESSIM_FRONTEND=<name> (default tui; 'none' = core +
tests only, no toolkit fetched). FTXUI moved into the tui frontend. Tests are
split: essim_tests links essim_core (no FTXUI), essim_tui_tests links essim_tui.
Verified: default tui build green (ctest 2/2); ESSIM_FRONTEND=none builds the
core + tests with FTXUI never fetched and no `essim` binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
301 lines
12 KiB
C++
301 lines
12 KiB
C++
#include "frontends/tui/tui.hpp"
|
|
#include "frontends/tui/tui_helpers.hpp"
|
|
|
|
#include <ftxui/component/component.hpp>
|
|
#include <ftxui/component/component_options.hpp>
|
|
#include <ftxui/component/event.hpp>
|
|
#include <ftxui/dom/elements.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <exception>
|
|
#include <filesystem>
|
|
|
|
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<Tui::FilenameFilter> &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<FilenameFilter> filters,
|
|
std::function<void(const std::string &)> 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<std::pair<std::string, bool>> 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);
|
|
});
|
|
}
|