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:
2026-05-16 13:36:05 +02:00
parent aa59d1a041
commit 3be5bc3f6e
5 changed files with 288 additions and 26 deletions

View File

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

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

View File

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

View File

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

View File

@@ -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