fix(market): make portfolio-editor dismiss gestures consistently auto-save

The Close button auto-committed the current group, but Escape, outside-click,
switching groups, adding a group, and deleting another group all *discarded*
uncommitted edits — so whether your work was kept depended on how you happened
to leave, and the prominent unsaved-dot / Save / Revert affordances implied a
save model the exits didn't honor.

Align every dismiss/switch gesture with the Close button's intent (auto-save):
- Escape and outside-click now commit before closing (outside-click is caught
  right after BeginOverlayDialog, which clears open mid-frame).
- Switching to another group, "Add entry", and deleting a *different* group
  commit the current group first. Deleting the selected group still discards
  its own working state.

Save remains "persist now", Revert "undo to last saved", and the dot marks an
in-progress group. Ordering is safe against the placeholder GC: that GC runs
earlier in the frame (mktDrawPortfolio, before RenderPortfolioEditor) and only
while the editor is closed, so it never shifts entry indices under a commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:03:17 -05:00
parent 2a9d32e78b
commit 3aeb847657

View File

@@ -734,9 +734,9 @@ static void RenderPortfolioEditor(App* app)
// ---------------- Full-window blur overlay (shared framework) ---------------- // ---------------- Full-window blur overlay (shared framework) ----------------
// Backdrop + capture-once, floating card, plain heading, outside-click dismiss and effect // Backdrop + capture-once, floating card, plain heading, outside-click dismiss and effect
// suppression are owned by material::BeginOverlayDialog (BlurFloat). Esc still closes here; // suppression are owned by material::BeginOverlayDialog (BlurFloat). Every dismiss gesture
// Esc/outside-click discard (only Close commits); LatchBlurOverlayActive (App::render) handles // (Close button, Esc, outside-click, switching/adding/deleting groups) auto-saves the current
// the acrylic re-capture on close. // group via pfCommitIfNeeded; LatchBlurOverlayActive (App::render) does the acrylic re-capture.
material::OverlayDialogSpec ov; material::OverlayDialogSpec ov;
ov.title = TR("portfolio_manage_title"); ov.title = TR("portfolio_manage_title");
ov.p_open = &s_pfEdit.open; ov.p_open = &s_pfEdit.open;
@@ -745,11 +745,18 @@ static void RenderPortfolioEditor(App* app)
ov.cardHeight = 760.0f; ov.cardHeight = 760.0f;
ov.idSuffix = "pf"; ov.idSuffix = "pf";
if (!material::BeginOverlayDialog(ov)) return; if (!material::BeginOverlayDialog(ov)) return;
// Outside-click dismiss is handled inside BeginOverlayDialog, which clears s_pfEdit.open mid-frame
// (the card still draws one final frame). Auto-save now, before that frame: the placeholder GC in
// mktDrawPortfolio already ran this frame while open was still true, so entry indices haven't
// shifted under us and pfCommitIfNeeded targets the right slot.
if (!s_pfEdit.open) pfCommitIfNeeded(app);
// A first Escape should dismiss only an open popup (e.g. the custom-color picker); don't also tear // A first Escape should dismiss only an open popup (e.g. the custom-color picker); don't also tear
// down the whole editor — which discards uncommitted edits — while a popup is capturing the key. // down the whole editor while a popup is capturing the key. Otherwise Escape auto-saves and closes.
if (ImGui::IsKeyPressed(ImGuiKey_Escape) && if (ImGui::IsKeyPressed(ImGuiKey_Escape) &&
!ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) {
pfCommitIfNeeded(app);
s_pfEdit.open = false; s_pfEdit.open = false;
}
{ // Clamp/validate the selection. The in-editor delete already reloads the working state, but if the { // Clamp/validate the selection. The in-editor delete already reloads the working state, but if the
// list shrank via any other path, reload after clamping so a later commit can't overlay stale // list shrank via any other path, reload after clamping so a later commit can't overlay stale
@@ -845,7 +852,9 @@ static void RenderPortfolioEditor(App* app)
ImGui::PopID(); ImGui::PopID();
} }
if (delRow >= 0) { if (delRow >= 0) {
// Operates on the stored entries; any uncommitted working edits are discarded. // Auto-save edits to a *different* selected group before mutating the list; only the
// deleted row's own working state is dropped. (Re-read entries after the commit.)
if (delRow != s_pfEdit.sel) pfCommitIfNeeded(app);
auto es = settings->getPortfolioEntries(); auto es = settings->getPortfolioEntries();
if (delRow < (int)es.size()) { if (delRow < (int)es.size()) {
es.erase(es.begin() + delRow); es.erase(es.begin() + delRow);
@@ -856,14 +865,16 @@ static void RenderPortfolioEditor(App* app)
PortfolioBeginEdit(app, s_pfEdit.sel); PortfolioBeginEdit(app, s_pfEdit.sel);
} }
} else if (clickedSel != -999) { } else if (clickedSel != -999) {
PortfolioBeginEdit(app, clickedSel); // switching groups discards uncommitted edits pfCommitIfNeeded(app); // auto-save the current group before switching
PortfolioBeginEdit(app, clickedSel);
} }
} }
ImGui::EndChild(); ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Add immediately creates a persisted "Untitled" group and selects it for editing. // Add immediately creates a persisted "Untitled" group and selects it for editing.
if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) { if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
// Adding switches to a new group; uncommitted edits to the current one are discarded. // Auto-save the current group, then start a fresh one selected for editing.
pfCommitIfNeeded(app);
auto es = settings->getPortfolioEntries(); auto es = settings->getPortfolioEntries();
config::Settings::PortfolioEntry ne; config::Settings::PortfolioEntry ne;
ne.label = TR("portfolio_untitled"); ne.label = TR("portfolio_untitled");