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 {
|
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
|
// Build the `content.xml` payload for the spreadsheet. Returns the
|
||||||
// serialised UTF-8 string. The minimum schema the format requires for
|
// serialised UTF-8 string. Minimum-but-styled: includes header / zebra
|
||||||
// a string-only multi-sheet workbook.
|
// 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) {
|
std::string build_content_xml(const std::vector<OdsSheet> &sheets) {
|
||||||
pugi::xml_document doc;
|
pugi::xml_document doc;
|
||||||
auto decl = doc.append_child(pugi::node_declaration);
|
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";
|
"urn:oasis:names:tc:opendocument:xmlns:table:1.0";
|
||||||
root.append_attribute("xmlns:text") =
|
root.append_attribute("xmlns:text") =
|
||||||
"urn:oasis:names:tc:opendocument:xmlns:text:1.0";
|
"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";
|
root.append_attribute("office:version") = "1.2";
|
||||||
|
|
||||||
|
append_styles(root);
|
||||||
|
|
||||||
auto body = root.append_child("office:body");
|
auto body = root.append_child("office:body");
|
||||||
auto sheet_root = body.append_child("office:spreadsheet");
|
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();
|
int cols = s.cols();
|
||||||
for (int r = 0; r < rows; ++r) {
|
for (int r = 0; r < rows; ++r) {
|
||||||
auto row = table.append_child("table:table-row");
|
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) {
|
for (int c = 0; c < cols; ++c) {
|
||||||
auto cell = row.append_child("table:table-cell");
|
auto cell = row.append_child("table:table-cell");
|
||||||
|
cell.append_attribute("table:style-name") = style;
|
||||||
cell.append_attribute("office:value-type") = "string";
|
cell.append_attribute("office:value-type") = "string";
|
||||||
auto p = cell.append_child("text:p");
|
auto p = cell.append_child("text:p");
|
||||||
p.append_child(pugi::node_pcdata).set_value(s.cell(r, c).c_str());
|
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;
|
std::ostringstream oss;
|
||||||
// We added the declaration node explicitly above, so pugi will emit
|
// We added the declaration node explicitly above, so pugi will emit
|
||||||
// it. `format_no_declaration` only suppresses *implicit* injection;
|
// 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: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="/" 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="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";
|
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
|
// 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
|
// buffer must outlive zip_close, which is why callers keep a vector of
|
||||||
// owned strings alive until after the close.
|
// 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 mimetype = "application/vnd.oasis.opendocument.spreadsheet";
|
||||||
std::string manifest = build_manifest_xml();
|
std::string manifest = build_manifest_xml();
|
||||||
std::string content = build_content_xml(sheets_);
|
std::string content = build_content_xml(sheets_);
|
||||||
|
std::string settings = build_settings_xml(sheets_);
|
||||||
|
|
||||||
if (!add_string_entry(zip, "mimetype", mimetype, /*store=*/true, error) ||
|
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, "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);
|
zip_discard(zip);
|
||||||
return false;
|
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;
|
file_dialog.entry_idx = 0;
|
||||||
// Default focus: filename input. With filters present that's slot 2;
|
// Default focus: filename input (Container child 2).
|
||||||
// without, slot 1.
|
file_dialog.focus_idx = 2;
|
||||||
file_dialog.focus_idx = file_dialog.filters.empty() ? 1 : 2;
|
|
||||||
file_dialog.open = true;
|
file_dialog.open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +116,19 @@ void Tui::ConfirmFileDialog() {
|
|||||||
SaveLastUsed(file_dialog.persist_key,
|
SaveLastUsed(file_dialog.persist_key,
|
||||||
file_dialog.dir, file_dialog.filename);
|
file_dialog.dir, file_dialog.filename);
|
||||||
file_dialog.open = false;
|
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() {
|
Component Tui::BuildFileDialog() {
|
||||||
@@ -146,7 +157,7 @@ Component Tui::BuildFileDialog() {
|
|||||||
ext_of(file_dialog.filename));
|
ext_of(file_dialog.filename));
|
||||||
if (i >= 0) file_dialog.filter_idx = i;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
@@ -165,7 +176,15 @@ Component Tui::BuildFileDialog() {
|
|||||||
// lambda below (FTXUI's plain Toggle API has no on_change hook).
|
// 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 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(
|
auto container = Container::Vertical(
|
||||||
{filter_toggle, entries_menu, filename_input, button},
|
{filter_toggle, entries_menu, filename_input, button},
|
||||||
@@ -173,14 +192,14 @@ Component Tui::BuildFileDialog() {
|
|||||||
|
|
||||||
auto handler = CatchEvent(container, [this](Event e) {
|
auto handler = CatchEvent(container, [this](Event e) {
|
||||||
if (e == Event::Escape) { file_dialog.open = false; return true; }
|
if (e == Event::Escape) { file_dialog.open = false; return true; }
|
||||||
// The number of focusable slots depends on whether filters are
|
// Container has 4 children at indices 0..3: filter_toggle,
|
||||||
// present (4 with, 3 without). Skip slot 0 (filters) when empty.
|
// entries_menu, filename_input, button. `focus_idx` indexes
|
||||||
int n = file_dialog.filters.empty() ? 3 : 4;
|
// 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;
|
int base = file_dialog.filters.empty() ? 1 : 0;
|
||||||
if (e == Event::Tab) {
|
if (e == Event::Tab) {
|
||||||
do { file_dialog.focus_idx = (file_dialog.focus_idx + 1) % 4;
|
do { file_dialog.focus_idx = (file_dialog.focus_idx + 1) % 4;
|
||||||
} while (file_dialog.focus_idx < base);
|
} while (file_dialog.focus_idx < base);
|
||||||
(void)n;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e == Event::TabReverse) {
|
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(hbox({text(" dir: ") | dim, text(file_dialog.dir)}));
|
||||||
rows.push_back(separator());
|
rows.push_back(separator());
|
||||||
int entries_focus = file_dialog.filters.empty() ? 0 : 1;
|
// Focus index maps directly to the Container::Vertical child
|
||||||
int fname_focus = file_dialog.filters.empty() ? 1 : 2;
|
// index — 1 = entries, 2 = filename, 3 = button (0 = filter
|
||||||
int btn_focus = file_dialog.filters.empty() ? 2 : 3;
|
// toggle, only meaningful when filters are present).
|
||||||
rows.push_back(FocusLabel(text(" entries "),
|
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
|
rows.push_back(entries_menu->Render() | vscroll_indicator | yframe
|
||||||
| size(HEIGHT, LESS_THAN, 16) | size(HEIGHT, GREATER_THAN, 6));
|
| size(HEIGHT, LESS_THAN, 16) | size(HEIGHT, GREATER_THAN, 6));
|
||||||
rows.push_back(separator());
|
rows.push_back(separator());
|
||||||
rows.push_back(hbox({
|
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,
|
filename_input->Render() | flex,
|
||||||
}) | border);
|
}) | border);
|
||||||
rows.push_back(separator());
|
rows.push_back(separator());
|
||||||
rows.push_back(hbox({filler(),
|
// The button's own ButtonOption::transform already inverts the
|
||||||
FocusLabel(button->Render(),
|
// label when focused — no outer FocusLabel here (it would
|
||||||
file_dialog.focus_idx == btn_focus),
|
// double-invert and cancel out).
|
||||||
filler()}));
|
rows.push_back(hbox({filler(), button->Render(), filler()}));
|
||||||
rows.push_back(status);
|
rows.push_back(status);
|
||||||
rows.push_back(text(" Tab cycle • Enter: cd / pick / confirm • Esc cancel ") | dim);
|
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_palette = tab | Modal(BuildPaletteModal(), &palette_open);
|
||||||
auto with_dialog = with_palette
|
auto with_dialog = with_palette
|
||||||
| Modal(BuildFileDialog(), &file_dialog.open);
|
| 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);
|
| Modal(BuildErrorModal(), &error_open);
|
||||||
|
|
||||||
auto root = CatchEvent(with_error, [this](Event e) {
|
auto root = CatchEvent(with_error, [this](Event e) {
|
||||||
// Modals own their events while open. Error modal has priority.
|
// Modals own their events while open. Error modal sits on top.
|
||||||
if (error_open || palette_open || sigtype_dialog_open
|
if (error_open || confirm_open || palette_open
|
||||||
|| file_dialog.open) return false;
|
|| sigtype_dialog_open || file_dialog.open) return false;
|
||||||
|
|
||||||
// Ctrl-P opens the palette from any screen.
|
// Ctrl-P opens the palette from any screen.
|
||||||
if (e == Event::CtrlP) { OpenPalette(); return true; }
|
if (e == Event::CtrlP) { OpenPalette(); return true; }
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ class Tui {
|
|||||||
bool error_open = false;
|
bool error_open = false;
|
||||||
std::string error_message;
|
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) ----
|
// ---- Generic file-picker dialog (modal) ----
|
||||||
// Reused for any "pick a path" interaction (export, save, restore, …).
|
// Reused for any "pick a path" interaction (export, save, restore, …).
|
||||||
// `persist_key` ties the dir + filename to a key persisted under the
|
// `persist_key` ties the dir + filename to a key persisted under the
|
||||||
@@ -251,10 +256,15 @@ private:
|
|||||||
ftxui::Component BuildHelpScreen();
|
ftxui::Component BuildHelpScreen();
|
||||||
ftxui::Component BuildFileDialog();
|
ftxui::Component BuildFileDialog();
|
||||||
ftxui::Component BuildErrorModal();
|
ftxui::Component BuildErrorModal();
|
||||||
|
ftxui::Component BuildConfirmModal();
|
||||||
// Pop a centred modal with `msg` and an OK button. Esc / Enter close
|
// Pop a centred modal with `msg` and an OK button. Esc / Enter close
|
||||||
// it. Use for actionable failures the user must see (write errors,
|
// it. Use for actionable failures the user must see (write errors,
|
||||||
// bad inputs, etc.) — for normal feedback keep `Print()`.
|
// bad inputs, etc.) — for normal feedback keep `Print()`.
|
||||||
void ShowError(const std::string &msg);
|
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
|
// Open the picker modal. `persist_key` controls where the last-used
|
||||||
// dir + filename are stored (one tiny file per key under the user
|
// dir + filename are stored (one tiny file per key under the user
|
||||||
// data directory). `on_confirm` runs when the user presses Enter on
|
// data directory). `on_confirm` runs when the user presses Enter on
|
||||||
|
|||||||
Reference in New Issue
Block a user