Files
essim/src/frontends/tui/screen_filedialog.cpp
François 63ca17d048 build: split core/ from frontends/; prepare for multiple GUI/TUI targets
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>
2026-06-03 19:33:06 +02:00

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);
});
}