From 3be5bc3f6e054d6d2745d30ba6395ca0d000f984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Sat, 16 May 2026 13:36:05 +0200 Subject: [PATCH] ODS polish + reusable confirm modal + file-dialog focus/overwrite fixes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ODS writer: - Header row styled bold on a grey background; data rows alternate light-grey / white (zebra). Auto-filter buttons enabled per sheet via `` — 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 --- src/imports/ods_writer.cpp | 175 +++++++++++++++++++++++++++++++++- src/tui/screen_confirm.cpp | 62 ++++++++++++ src/tui/screen_filedialog.cpp | 57 +++++++---- src/tui/tui.cpp | 10 +- src/tui/tui.hpp | 10 ++ 5 files changed, 288 insertions(+), 26 deletions(-) create mode 100644 src/tui/screen_confirm.cpp diff --git a/src/imports/ods_writer.cpp b/src/imports/ods_writer.cpp index 66d459d..47d7df0 100644 --- a/src/imports/ods_writer.cpp +++ b/src/imports/ods_writer.cpp @@ -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 &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 &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 &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 &sheets) { } } + // Auto-filter per sheet: one with display- + // filter-buttons=true covering the full grid (header + data). The + // range address syntax is `.A1:`. 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"()" "\n" R"( )" "\n" R"( )" "\n" + R"( )" "\n" R"()" "\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 &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; } diff --git a/src/tui/screen_confirm.cpp b/src/tui/screen_confirm.cpp new file mode 100644 index 0000000..5a3931a --- /dev/null +++ b/src/tui/screen_confirm.cpp @@ -0,0 +1,62 @@ +#include "tui/tui.hpp" + +#include +#include +#include + +#include + +using namespace ftxui; + +void Tui::ShowConfirm(const std::string &msg, std::function 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); + }); +} diff --git a/src/tui/screen_filedialog.cpp b/src/tui/screen_filedialog.cpp index cbc92a6..8885f2e 100644 --- a/src/tui/screen_filedialog.cpp +++ b/src/tui/screen_filedialog.cpp @@ -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); diff --git a/src/tui/tui.cpp b/src/tui/tui.cpp index 4b81fdd..c8e130e 100644 --- a/src/tui/tui.cpp +++ b/src/tui/tui.cpp @@ -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; } diff --git a/src/tui/tui.hpp b/src/tui/tui.hpp index 71e9718..b61c428 100644 --- a/src/tui/tui.hpp +++ b/src/tui/tui.hpp @@ -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 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 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