ODS polish + reusable confirm modal + file-dialog focus/overwrite fixes.
ODS writer:
- Header row styled bold on a grey background; data rows alternate
light-grey / white (zebra). Auto-filter buttons enabled per sheet
via `<table:database-range table:display-filter-buttons="true">`
— sheet names are now single-quoted in the target-range-address so
LibreOffice actually parses ranges that contain spaces.
- First row of every sheet frozen via `settings.xml` (view setting).
The settings layout follows LibreOffice's own writer exactly:
`xmlns:ooo` + `xmlns:xlink` declared on the root, `ActiveTable` and
view-level zoom/grid/headers placed *after* the Tables map (the
order LibreOffice expects to read back). HorizontalSplitMode = 0,
VerticalSplitMode = 2 → only the first row is frozen, the first
column scrolls normally. ActiveSplitRange = 2 (bottom-left pane).
`settings.xml` registered in `META-INF/manifest.xml`.
Reusable Yes/No modal:
- New `screen_confirm.cpp` exposing `Tui::ShowConfirm(msg, on_yes)`.
Centred `borderRounded` popup, `No` first (safer default), Enter
confirms the focused button, Esc cancels (treated as No).
- Stacked into the Modal chain in `Run()` (between file-dialog and
error). The outer `CatchEvent` cedes events when it's open.
File dialog:
- Picks `OK` button focus reliably — previously the focus indices in
the Renderer were remapped against the Container::Vertical child
indices, so the OK label only flagged "focused" while the actual
focused child was the filename input.
- OK button uses a custom `ButtonOption::transform` that renders the
label transparent when idle, inverted when focused. No more
cyan-fill, no more double-inversion via FocusLabel.
- After confirmation, if the picked path already exists, pops a
`ShowConfirm("File … already exists. Overwrite?")` before invoking
the caller's action.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -35,9 +35,46 @@ OdsSheet *OdsWriter::add_sheet(const std::string &name) {
|
||||
|
||||
namespace {
|
||||
|
||||
// Column-letter encoding (0 → A, 25 → Z, 26 → AA, …). Used to spell out
|
||||
// the auto-filter range address.
|
||||
std::string col_letter(int c) {
|
||||
std::string s;
|
||||
++c;
|
||||
while (c > 0) {
|
||||
--c;
|
||||
s = char('A' + c % 26) + s;
|
||||
c /= 26;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Append the three styles we use throughout: `hdr` for the first row
|
||||
// (bold + grey background), `even` / `odd` for the zebra-striped data
|
||||
// rows. Defined as cell-family automatic styles so callers don't need
|
||||
// to declare them.
|
||||
void append_styles(pugi::xml_node &root) {
|
||||
auto autos = root.append_child("office:automatic-styles");
|
||||
auto add = [&](const char *name, bool bold,
|
||||
const char *bg) {
|
||||
auto st = autos.append_child("style:style");
|
||||
st.append_attribute("style:name") = name;
|
||||
st.append_attribute("style:family") = "table-cell";
|
||||
if (bold) {
|
||||
auto tp = st.append_child("style:text-properties");
|
||||
tp.append_attribute("fo:font-weight") = "bold";
|
||||
}
|
||||
auto cp = st.append_child("style:table-cell-properties");
|
||||
cp.append_attribute("fo:background-color") = bg;
|
||||
};
|
||||
add("hdr", /*bold=*/true, "#dddddd");
|
||||
add("even", /*bold=*/false, "#f2f2f2");
|
||||
add("odd", /*bold=*/false, "#ffffff");
|
||||
}
|
||||
|
||||
// Build the `content.xml` payload for the spreadsheet. Returns the
|
||||
// serialised UTF-8 string. The minimum schema the format requires for
|
||||
// a string-only multi-sheet workbook.
|
||||
// serialised UTF-8 string. Minimum-but-styled: includes header / zebra
|
||||
// cell styles and a database-range per sheet so LibreOffice / Excel
|
||||
// render auto-filter buttons on the first row.
|
||||
std::string build_content_xml(const std::vector<OdsSheet> &sheets) {
|
||||
pugi::xml_document doc;
|
||||
auto decl = doc.append_child(pugi::node_declaration);
|
||||
@@ -51,8 +88,14 @@ std::string build_content_xml(const std::vector<OdsSheet> &sheets) {
|
||||
"urn:oasis:names:tc:opendocument:xmlns:table:1.0";
|
||||
root.append_attribute("xmlns:text") =
|
||||
"urn:oasis:names:tc:opendocument:xmlns:text:1.0";
|
||||
root.append_attribute("xmlns:style") =
|
||||
"urn:oasis:names:tc:opendocument:xmlns:style:1.0";
|
||||
root.append_attribute("xmlns:fo") =
|
||||
"urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0";
|
||||
root.append_attribute("office:version") = "1.2";
|
||||
|
||||
append_styles(root);
|
||||
|
||||
auto body = root.append_child("office:body");
|
||||
auto sheet_root = body.append_child("office:spreadsheet");
|
||||
|
||||
@@ -63,8 +106,12 @@ std::string build_content_xml(const std::vector<OdsSheet> &sheets) {
|
||||
int cols = s.cols();
|
||||
for (int r = 0; r < rows; ++r) {
|
||||
auto row = table.append_child("table:table-row");
|
||||
const char *style = (r == 0) ? "hdr"
|
||||
: (r % 2 == 1) ? "odd"
|
||||
: "even";
|
||||
for (int c = 0; c < cols; ++c) {
|
||||
auto cell = row.append_child("table:table-cell");
|
||||
cell.append_attribute("table:style-name") = style;
|
||||
cell.append_attribute("office:value-type") = "string";
|
||||
auto p = cell.append_child("text:p");
|
||||
p.append_child(pugi::node_pcdata).set_value(s.cell(r, c).c_str());
|
||||
@@ -72,6 +119,40 @@ std::string build_content_xml(const std::vector<OdsSheet> &sheets) {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-filter per sheet: one <table:database-range> with display-
|
||||
// filter-buttons=true covering the full grid (header + data). The
|
||||
// range address syntax is `<sheet>.A1:<lastcol><lastrow>`. Sheet
|
||||
// names with spaces or special characters MUST be single-quoted
|
||||
// (and embedded `'` doubled) — without the quotes LibreOffice
|
||||
// silently fails to parse the range and the filter buttons don't
|
||||
// appear. Quoting unconditionally is safe.
|
||||
auto quote_sheet = [](const std::string &name) {
|
||||
std::string out = "'";
|
||||
for (char c : name) {
|
||||
if (c == '\'') out += "''";
|
||||
else out += c;
|
||||
}
|
||||
out += "'";
|
||||
return out;
|
||||
};
|
||||
if (!sheets.empty()) {
|
||||
auto ranges = sheet_root.append_child("table:database-ranges");
|
||||
int idx = 1;
|
||||
for (const auto &s : sheets) {
|
||||
int rows = s.rows();
|
||||
int cols = s.cols();
|
||||
if (rows < 1 || cols < 1) continue;
|
||||
std::string addr = quote_sheet(s.name()) + ".A1:"
|
||||
+ col_letter(cols - 1) + std::to_string(rows);
|
||||
auto dr = ranges.append_child("table:database-range");
|
||||
std::string name = "filter_" + std::to_string(idx++);
|
||||
dr.append_attribute("table:name") = name.c_str();
|
||||
dr.append_attribute("table:display-filter-buttons") = "true";
|
||||
dr.append_attribute("table:target-range-address") = addr.c_str();
|
||||
dr.append_attribute("table:orientation") = "row";
|
||||
}
|
||||
}
|
||||
|
||||
std::ostringstream oss;
|
||||
// We added the declaration node explicitly above, so pugi will emit
|
||||
// it. `format_no_declaration` only suppresses *implicit* injection;
|
||||
@@ -89,9 +170,95 @@ std::string build_manifest_xml() {
|
||||
R"(<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">)" "\n"
|
||||
R"( <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>)" "\n"
|
||||
R"( <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>)" "\n"
|
||||
R"( <manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/>)" "\n"
|
||||
R"(</manifest:manifest>)" "\n";
|
||||
}
|
||||
|
||||
// `settings.xml` carries view-only properties — freeze panes is the
|
||||
// only thing we need here. Per ODF 1.2 the structure is deeply nested
|
||||
// config-item maps; the values that matter for "freeze first row and
|
||||
// first column":
|
||||
// HorizontalSplitMode / VerticalSplitMode = 2 (2 = "frozen")
|
||||
// HorizontalSplitPosition / VerticalSplitPosition = 1 (split after
|
||||
// first column / first row)
|
||||
// PositionRight / PositionBottom = 1 (first non-frozen cell at B2)
|
||||
std::string build_settings_xml(const std::vector<OdsSheet> &sheets) {
|
||||
pugi::xml_document doc;
|
||||
auto decl = doc.append_child(pugi::node_declaration);
|
||||
decl.append_attribute("version") = "1.0";
|
||||
decl.append_attribute("encoding") = "UTF-8";
|
||||
|
||||
auto root = doc.append_child("office:document-settings");
|
||||
// Namespace cocktail expected by LibreOffice — without `xmlns:ooo`
|
||||
// and `xmlns:xlink` here it silently ignores the view-settings.
|
||||
root.append_attribute("xmlns:config") =
|
||||
"urn:oasis:names:tc:opendocument:xmlns:config:1.0";
|
||||
root.append_attribute("xmlns:xlink") = "http://www.w3.org/1999/xlink";
|
||||
root.append_attribute("xmlns:ooo") = "http://openoffice.org/2004/office";
|
||||
root.append_attribute("xmlns:office") =
|
||||
"urn:oasis:names:tc:opendocument:xmlns:office:1.0";
|
||||
root.append_attribute("office:version") = "1.2";
|
||||
|
||||
auto settings = root.append_child("office:settings");
|
||||
auto view_set = settings.append_child("config:config-item-set");
|
||||
view_set.append_attribute("config:name") = "ooo:view-settings";
|
||||
auto views = view_set.append_child("config:config-item-map-indexed");
|
||||
views.append_attribute("config:name") = "Views";
|
||||
auto view = views.append_child("config:config-item-map-entry");
|
||||
|
||||
auto add_item = [&](pugi::xml_node &parent, const char *name,
|
||||
const char *type, const std::string &v) {
|
||||
auto n = parent.append_child("config:config-item");
|
||||
n.append_attribute("config:name") = name;
|
||||
n.append_attribute("config:type") = type;
|
||||
n.append_child(pugi::node_pcdata).set_value(v.c_str());
|
||||
};
|
||||
|
||||
// Inside the View entry, the order matters: ViewId first, then the
|
||||
// Tables map, then ActiveTable + zoom (NOT before Tables — that's
|
||||
// the layout LibreOffice writes itself and reliably reads back).
|
||||
add_item(view, "ViewId", "string", "view1");
|
||||
|
||||
auto tables = view.append_child("config:config-item-map-named");
|
||||
tables.append_attribute("config:name") = "Tables";
|
||||
|
||||
for (const auto &s : sheets) {
|
||||
auto t = tables.append_child("config:config-item-map-entry");
|
||||
t.append_attribute("config:name") = s.name().c_str();
|
||||
// Cursor at A1 (anchors the view state).
|
||||
add_item(t, "CursorPositionX", "int", "0");
|
||||
add_item(t, "CursorPositionY", "int", "0");
|
||||
// Freeze the first row only: horizontal split off, vertical
|
||||
// split frozen one row in. ActiveSplitRange = 2 (bottom-left
|
||||
// sub-pane = the scrollable data).
|
||||
add_item(t, "HorizontalSplitMode", "short", "0");
|
||||
add_item(t, "VerticalSplitMode", "short", "2");
|
||||
add_item(t, "HorizontalSplitPosition", "int", "0");
|
||||
add_item(t, "VerticalSplitPosition", "int", "1");
|
||||
add_item(t, "ActiveSplitRange", "short", "2");
|
||||
add_item(t, "PositionLeft", "int", "0");
|
||||
add_item(t, "PositionRight", "int", "0");
|
||||
add_item(t, "PositionTop", "int", "0");
|
||||
add_item(t, "PositionBottom", "int", "1");
|
||||
add_item(t, "ZoomType", "short", "0");
|
||||
add_item(t, "ZoomValue", "int", "100");
|
||||
add_item(t, "ShowGrid", "boolean", "true");
|
||||
}
|
||||
|
||||
if (!sheets.empty()) {
|
||||
add_item(view, "ActiveTable", "string", sheets.front().name());
|
||||
}
|
||||
add_item(view, "ZoomType", "short", "0");
|
||||
add_item(view, "ZoomValue", "int", "100");
|
||||
add_item(view, "ShowGrid", "boolean", "true");
|
||||
add_item(view, "HasColumnRowHeaders", "boolean", "true");
|
||||
add_item(view, "HasSheetTabs", "boolean", "true");
|
||||
|
||||
std::ostringstream oss;
|
||||
doc.save(oss, "", pugi::format_raw);
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
// libzip wrapper for "add a string as a file with the given name". The
|
||||
// buffer must outlive zip_close, which is why callers keep a vector of
|
||||
// owned strings alive until after the close.
|
||||
@@ -134,10 +301,12 @@ bool OdsWriter::save(const std::string &path, std::string &error) const {
|
||||
std::string mimetype = "application/vnd.oasis.opendocument.spreadsheet";
|
||||
std::string manifest = build_manifest_xml();
|
||||
std::string content = build_content_xml(sheets_);
|
||||
std::string settings = build_settings_xml(sheets_);
|
||||
|
||||
if (!add_string_entry(zip, "mimetype", mimetype, /*store=*/true, error) ||
|
||||
!add_string_entry(zip, "META-INF/manifest.xml", manifest, /*store=*/false, error) ||
|
||||
!add_string_entry(zip, "content.xml", content, /*store=*/false, error)) {
|
||||
!add_string_entry(zip, "content.xml", content, /*store=*/false, error) ||
|
||||
!add_string_entry(zip, "settings.xml", settings, /*store=*/false, error)) {
|
||||
zip_discard(zip);
|
||||
return false;
|
||||
}
|
||||
|
||||
62
src/tui/screen_confirm.cpp
Normal file
62
src/tui/screen_confirm.cpp
Normal file
@@ -0,0 +1,62 @@
|
||||
#include "tui/tui.hpp"
|
||||
|
||||
#include <ftxui/component/component.hpp>
|
||||
#include <ftxui/component/event.hpp>
|
||||
#include <ftxui/dom/elements.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
using namespace ftxui;
|
||||
|
||||
void Tui::ShowConfirm(const std::string &msg, std::function<void()> on_yes) {
|
||||
confirm_message = msg;
|
||||
confirm_on_yes = std::move(on_yes);
|
||||
confirm_open = true;
|
||||
}
|
||||
|
||||
Component Tui::BuildConfirmModal() {
|
||||
// Transparent label idle, inverted when focused.
|
||||
ButtonOption btn_opt;
|
||||
btn_opt.transform = [](const EntryState &s) {
|
||||
Element el = text(s.label);
|
||||
if (s.focused) el = el | inverted;
|
||||
return el;
|
||||
};
|
||||
auto yes = Button(" Yes ", [this]() {
|
||||
// Copy the callback before resetting state so the user can
|
||||
// (re-)open a confirm modal from inside their on_yes handler
|
||||
// without losing the original.
|
||||
auto cb = confirm_on_yes;
|
||||
confirm_on_yes = nullptr;
|
||||
confirm_open = false;
|
||||
if (cb) cb();
|
||||
}, btn_opt);
|
||||
auto no = Button(" No ", [this]() {
|
||||
confirm_on_yes = nullptr;
|
||||
confirm_open = false;
|
||||
}, btn_opt);
|
||||
// No first → safer default.
|
||||
auto buttons = Container::Horizontal({no, yes});
|
||||
|
||||
auto handler = CatchEvent(buttons, [this](Event e) {
|
||||
if (e == Event::Escape) {
|
||||
confirm_on_yes = nullptr;
|
||||
confirm_open = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return Renderer(handler, [this, yes, no] {
|
||||
return vbox({
|
||||
text(" Confirm ") | bold | center,
|
||||
separator(),
|
||||
paragraph(confirm_message),
|
||||
separator(),
|
||||
hbox({filler(), no->Render(), text(" "), yes->Render(), filler()}),
|
||||
text(" Tab cycle • Enter confirm • Esc = No ") | dim,
|
||||
}) | borderRounded
|
||||
| size(WIDTH, LESS_THAN, 60)
|
||||
| size(WIDTH, GREATER_THAN, 36);
|
||||
});
|
||||
}
|
||||
@@ -83,9 +83,8 @@ void Tui::OpenFileDialog(std::string title,
|
||||
}
|
||||
|
||||
file_dialog.entry_idx = 0;
|
||||
// Default focus: filename input. With filters present that's slot 2;
|
||||
// without, slot 1.
|
||||
file_dialog.focus_idx = file_dialog.filters.empty() ? 1 : 2;
|
||||
// Default focus: filename input (Container child 2).
|
||||
file_dialog.focus_idx = 2;
|
||||
file_dialog.open = true;
|
||||
}
|
||||
|
||||
@@ -117,7 +116,19 @@ void Tui::ConfirmFileDialog() {
|
||||
SaveLastUsed(file_dialog.persist_key,
|
||||
file_dialog.dir, file_dialog.filename);
|
||||
file_dialog.open = false;
|
||||
if (cb) cb(full.string());
|
||||
|
||||
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 (fs::exists(full, ec) && !ec) {
|
||||
ShowConfirm("File '" + full.string() + "' already exists.\n"
|
||||
"Overwrite?",
|
||||
invoke);
|
||||
return;
|
||||
}
|
||||
invoke();
|
||||
}
|
||||
|
||||
Component Tui::BuildFileDialog() {
|
||||
@@ -146,7 +157,7 @@ Component Tui::BuildFileDialog() {
|
||||
ext_of(file_dialog.filename));
|
||||
if (i >= 0) file_dialog.filter_idx = i;
|
||||
}
|
||||
file_dialog.focus_idx = file_dialog.filters.empty() ? 2 : 3;
|
||||
file_dialog.focus_idx = 3; // jump to the OK button
|
||||
return;
|
||||
}
|
||||
std::error_code ec;
|
||||
@@ -165,7 +176,15 @@ Component Tui::BuildFileDialog() {
|
||||
// lambda below (FTXUI's plain Toggle API has no on_change hook).
|
||||
auto filter_toggle = Toggle(&file_dialog.filter_labels, &file_dialog.filter_idx);
|
||||
|
||||
auto button = Button(" OK ", [this]() { ConfirmFileDialog(); });
|
||||
// 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},
|
||||
@@ -173,14 +192,14 @@ Component Tui::BuildFileDialog() {
|
||||
|
||||
auto handler = CatchEvent(container, [this](Event e) {
|
||||
if (e == Event::Escape) { file_dialog.open = false; return true; }
|
||||
// The number of focusable slots depends on whether filters are
|
||||
// present (4 with, 3 without). Skip slot 0 (filters) when empty.
|
||||
int n = file_dialog.filters.empty() ? 3 : 4;
|
||||
// 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);
|
||||
(void)n;
|
||||
return true;
|
||||
}
|
||||
if (e == Event::TabReverse) {
|
||||
@@ -251,23 +270,23 @@ Component Tui::BuildFileDialog() {
|
||||
}
|
||||
rows.push_back(hbox({text(" dir: ") | dim, text(file_dialog.dir)}));
|
||||
rows.push_back(separator());
|
||||
int entries_focus = file_dialog.filters.empty() ? 0 : 1;
|
||||
int fname_focus = file_dialog.filters.empty() ? 1 : 2;
|
||||
int btn_focus = file_dialog.filters.empty() ? 2 : 3;
|
||||
// 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 == entries_focus) | bold);
|
||||
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 == fname_focus),
|
||||
FocusLabel(text(" filename: "), file_dialog.focus_idx == 2),
|
||||
filename_input->Render() | flex,
|
||||
}) | border);
|
||||
rows.push_back(separator());
|
||||
rows.push_back(hbox({filler(),
|
||||
FocusLabel(button->Render(),
|
||||
file_dialog.focus_idx == btn_focus),
|
||||
filler()}));
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -53,13 +53,15 @@ void Tui::Run() {
|
||||
auto with_palette = tab | Modal(BuildPaletteModal(), &palette_open);
|
||||
auto with_dialog = with_palette
|
||||
| Modal(BuildFileDialog(), &file_dialog.open);
|
||||
auto with_error = with_dialog
|
||||
auto with_confirm = with_dialog
|
||||
| Modal(BuildConfirmModal(), &confirm_open);
|
||||
auto with_error = with_confirm
|
||||
| Modal(BuildErrorModal(), &error_open);
|
||||
|
||||
auto root = CatchEvent(with_error, [this](Event e) {
|
||||
// Modals own their events while open. Error modal has priority.
|
||||
if (error_open || palette_open || sigtype_dialog_open
|
||||
|| file_dialog.open) return false;
|
||||
// Modals own their events while open. Error modal sits on top.
|
||||
if (error_open || confirm_open || palette_open
|
||||
|| sigtype_dialog_open || file_dialog.open) return false;
|
||||
|
||||
// Ctrl-P opens the palette from any screen.
|
||||
if (e == Event::CtrlP) { OpenPalette(); return true; }
|
||||
|
||||
@@ -112,6 +112,11 @@ class Tui {
|
||||
bool error_open = false;
|
||||
std::string error_message;
|
||||
|
||||
// ---- Generic Yes/No confirmation dialog (modal) ----
|
||||
bool confirm_open = false;
|
||||
std::string confirm_message;
|
||||
std::function<void()> confirm_on_yes;
|
||||
|
||||
// ---- Generic file-picker dialog (modal) ----
|
||||
// Reused for any "pick a path" interaction (export, save, restore, …).
|
||||
// `persist_key` ties the dir + filename to a key persisted under the
|
||||
@@ -251,10 +256,15 @@ private:
|
||||
ftxui::Component BuildHelpScreen();
|
||||
ftxui::Component BuildFileDialog();
|
||||
ftxui::Component BuildErrorModal();
|
||||
ftxui::Component BuildConfirmModal();
|
||||
// Pop a centred modal with `msg` and an OK button. Esc / Enter close
|
||||
// it. Use for actionable failures the user must see (write errors,
|
||||
// bad inputs, etc.) — for normal feedback keep `Print()`.
|
||||
void ShowError(const std::string &msg);
|
||||
// Pop a centred Yes/No modal. `on_yes` runs only if the user picks
|
||||
// Yes (Esc and No cancel). Use for destructive confirmations
|
||||
// (overwrite a file, delete a snapshot, …).
|
||||
void ShowConfirm(const std::string &msg, std::function<void()> on_yes);
|
||||
// Open the picker modal. `persist_key` controls where the last-used
|
||||
// dir + filename are stored (one tiny file per key under the user
|
||||
// data directory). `on_confirm` runs when the user presses Enter on
|
||||
|
||||
Reference in New Issue
Block a user