From bdfc32f3e1326a99a64f24667ad553096b35c813 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 17 Jun 2026 12:02:00 +0100 Subject: [PATCH 1/7] Create popup window to edit outcome --- src/gui/efgdisplay.cc | 203 ++++++++++++++++++++++++++++++++++++++---- src/gui/efgdisplay.h | 4 + 2 files changed, 189 insertions(+), 18 deletions(-) diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index 5ab177bc7..82caadfb0 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -21,13 +21,14 @@ // #include // for std::min - +#include #include #ifndef WX_PRECOMP #include -#endif // WX_PRECOMP -#include // for drag-and-drop support +#endif // WX_PRECOMP +#include #include +#include #include "gambit.h" @@ -37,6 +38,175 @@ #include "valnumber.h" namespace Gambit::GUI { + +class OutcomeEditorPopup : public wxPopupTransientWindow { +public: + OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc); + + void BeginEdit(const GameNode &p_node, int p_initialPlayer = 0); + +protected: + void OnDismiss() override; + +private: + void BuildControls(); + void LoadValues(); + void PositionPopup(); + void OnKeyDown(wxKeyEvent &p_event); + + EfgDisplay *m_owner; + GameDocument *m_doc; + + GameNode m_node; + + wxTextCtrl *m_labelCtrl; + wxFlexGridSizer *m_gridSizer; + std::vector m_payoffCtrls; + + int m_initialPlayer{0}; + bool m_cancelled{false}; +}; + +OutcomeEditorPopup::OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc) + : wxPopupTransientWindow(p_owner, wxBORDER_SIMPLE), m_owner(p_owner), m_doc(p_doc), + m_labelCtrl(nullptr), m_gridSizer(nullptr) +{ + SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + BuildControls(); + + Bind(wxEVT_CHAR_HOOK, &OutcomeEditorPopup::OnKeyDown, this); +} + +void OutcomeEditorPopup::BuildControls() +{ + auto *outerSizer = new wxBoxSizer(wxVERTICAL); + + auto *heading = new wxStaticText(this, wxID_ANY, _("Outcome")); + wxFont headingFont = heading->GetFont(); + headingFont.SetWeight(wxFONTWEIGHT_BOLD); + heading->SetFont(headingFont); + + outerSizer->Add(heading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); + + m_gridSizer = new wxFlexGridSizer(2, FromDIP(6), FromDIP(10)); + m_gridSizer->AddGrowableCol(1, 1); + + m_gridSizer->Add(new wxStaticText(this, wxID_ANY, _("Label")), 0, wxALIGN_CENTER_VERTICAL); + + m_labelCtrl = new wxTextCtrl(this, wxID_ANY); + m_labelCtrl->SetMinSize(wxSize(FromDIP(180), -1)); + m_gridSizer->Add(m_labelCtrl, 1, wxEXPAND); + + auto *payoffHeading = new wxStaticText(this, wxID_ANY, _("Payoffs")); + wxFont payoffHeadingFont = payoffHeading->GetFont(); + payoffHeadingFont.SetWeight(wxFONTWEIGHT_BOLD); + payoffHeading->SetFont(payoffHeadingFont); + + outerSizer->Add(m_gridSizer, 0, wxEXPAND | wxALL, FromDIP(10)); + outerSizer->Add(payoffHeading, 0, wxLEFT | wxRIGHT, FromDIP(10)); + + auto *payoffSizer = new wxFlexGridSizer(2, FromDIP(6), FromDIP(10)); + payoffSizer->AddGrowableCol(1, 1); + + const Game game = m_doc->GetGame(); + + for (size_t player = 1; player <= m_doc->NumPlayers(); ++player) { + const GamePlayer gamePlayer = game->GetPlayer(player); + + payoffSizer->Add( + new wxStaticText(this, wxID_ANY, wxString(gamePlayer->GetLabel().c_str(), *wxConvCurrent)), + 0, wxALIGN_CENTER_VERTICAL); + + auto *payoffCtrl = new wxTextCtrl(this, wxID_ANY); + payoffCtrl->SetValidator(NumberValidator(nullptr)); + payoffCtrl->SetMinSize(wxSize(FromDIP(100), -1)); + + payoffSizer->Add(payoffCtrl, 1, wxEXPAND); + m_payoffCtrls.push_back(payoffCtrl); + } + + outerSizer->Add(payoffSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(10)); + + SetSizerAndFit(outerSizer); +} + +void OutcomeEditorPopup::LoadValues() +{ + const GameOutcome outcome = m_node ? m_node->GetOutcome() : nullptr; + + if (!outcome) { + m_labelCtrl->Clear(); + + for (auto *ctrl : m_payoffCtrls) { + ctrl->Clear(); + } + + return; + } + + m_labelCtrl->SetValue(wxString(outcome->GetLabel().c_str(), *wxConvCurrent)); + + const Game game = m_doc->GetGame(); + + for (size_t player = 1; player <= m_payoffCtrls.size(); ++player) { + const std::string payoff = outcome->GetPayoff(game->GetPlayer(player)); + + m_payoffCtrls[player - 1]->SetValue(wxString(payoff.c_str(), *wxConvCurrent)); + } +} + +void OutcomeEditorPopup::PositionPopup() +{ + auto entry = m_owner->GetLayout().GetNodeEntry(m_node); + if (!entry) { + return; + } + + int clientX, clientY; + m_owner->CalcScrolledPosition(m_owner->LayoutToDevice(entry->GetX() + 20), + m_owner->LayoutToDevice(entry->GetY()), &clientX, &clientY); + + const wxPoint screenPoint = m_owner->ClientToScreen(wxPoint(clientX, clientY)); + + Position(screenPoint, wxSize(FromDIP(8), FromDIP(8))); +} + +void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) +{ + m_node = p_node; + m_initialPlayer = p_initialPlayer; + m_cancelled = false; + + LoadValues(); + Fit(); + PositionPopup(); + + Popup(); + + if (m_initialPlayer > 0 && m_initialPlayer <= static_cast(m_payoffCtrls.size())) { + wxTextCtrl *ctrl = m_payoffCtrls[m_initialPlayer - 1]; + ctrl->SetFocus(); + ctrl->SelectAll(); + } + else { + m_labelCtrl->SetFocus(); + m_labelCtrl->SetInsertionPointEnd(); + } +} + +void OutcomeEditorPopup::OnDismiss() { m_node = nullptr; } + +void OutcomeEditorPopup::OnKeyDown(wxKeyEvent &p_event) +{ + if (p_event.GetKeyCode() == WXK_ESCAPE) { + m_cancelled = true; + Dismiss(); + return; + } + + p_event.Skip(); +} + //-------------------------------------------------------------------------- // class TreePayoffEditor //-------------------------------------------------------------------------- @@ -325,7 +495,8 @@ END_EVENT_TABLE() EfgDisplay::EfgDisplay(wxWindow *p_parent, GameDocument *p_doc) : wxScrolledWindow(p_parent), GameView(p_doc), m_layout(p_doc), m_zoom(100), - m_payoffEditor(new TreePayoffEditor(this)) + m_payoffEditor(new TreePayoffEditor(this)), + m_outcomeEditor(new OutcomeEditorPopup(this, p_doc)) { wxWindow::SetBackgroundColour(wxColour(250, 250, 250)); @@ -897,10 +1068,10 @@ void EfgDisplay::OnLeftDoubleClick(wxMouseEvent &p_event) node = m_layout.OutcomeHitTest(x, y); if (node) { if (!node->GetOutcome()) { - // Create a new outcome + // Retain the existing behaviour temporarily for outcomes which + // have not yet been created. m_doc->DoNewOutcome(node); - // Payoff rectangles are actually set during drawing, so - // force a refresh + wxClientDC dc(this); PrepareDC(dc); OnDraw(dc); @@ -917,21 +1088,17 @@ void EfgDisplay::OnLeftDoubleClick(wxMouseEvent &p_event) return; } - // Editing an existing outcome + int initialPlayer = 0; + auto entry = m_layout.GetNodeEntry(node); - for (size_t pl = 1; pl <= m_doc->NumPlayers(); pl++) { - const wxRect rect = entry->GetPayoffExtent(pl); - if (rect.Contains(x, y)) { - int xx, yy; - CalcScrolledPosition(LayoutToDevice(rect.x - 3), LayoutToDevice(rect.y - 3), &xx, &yy); - const int width = LayoutToDevice(rect.width + 10); - const int height = LayoutToDevice(rect.height + 6); - m_payoffEditor->SetSize(xx, yy, width, height); - m_payoffEditor->BeginEdit(entry, pl); - return; + for (size_t player = 1; player <= m_doc->NumPlayers(); ++player) { + if (entry->GetPayoffExtent(player).Contains(x, y)) { + initialPlayer = static_cast(player); + break; } } + m_outcomeEditor->BeginEdit(node, initialPlayer); return; } diff --git a/src/gui/efgdisplay.h b/src/gui/efgdisplay.h index 3f10e8d69..df8dacd89 100644 --- a/src/gui/efgdisplay.h +++ b/src/gui/efgdisplay.h @@ -27,6 +27,9 @@ #include "efglayout.h" namespace Gambit::GUI { + +class OutcomeEditorPopup; + class TreePayoffEditor final : public wxTextCtrl { std::shared_ptr m_entry{nullptr}; GameOutcome m_outcome; @@ -57,6 +60,7 @@ class EfgDisplay final : public wxScrolledWindow, public GameView { int m_zoom; wxMenu *m_nodeMenu{nullptr}; TreePayoffEditor *m_payoffEditor; + OutcomeEditorPopup *m_outcomeEditor; bool m_pendingInitialZoom{true}; void MakeMenus(); From 6763ab88dda4ae4e2285dc23690fe88c46c8c174 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 17 Jun 2026 12:11:45 +0100 Subject: [PATCH 2/7] Next steps on edit popup --- src/gui/efgdisplay.cc | 121 +++++++++++++++++++++++++++++++++++++++--- src/gui/gamedoc.cc | 44 +++++++++++++++ src/gui/gamedoc.h | 2 + 3 files changed, 160 insertions(+), 7 deletions(-) diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index 82caadfb0..639714868 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -44,6 +44,8 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc); void BeginEdit(const GameNode &p_node, int p_initialPlayer = 0); + bool Commit(); + void Cancel(); protected: void OnDismiss() override; @@ -53,6 +55,7 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { void LoadValues(); void PositionPopup(); void OnKeyDown(wxKeyEvent &p_event); + void RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl); EfgDisplay *m_owner; GameDocument *m_doc; @@ -65,6 +68,7 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { int m_initialPlayer{0}; bool m_cancelled{false}; + bool m_dismissing{false}; }; OutcomeEditorPopup::OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc) @@ -176,6 +180,7 @@ void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) m_node = p_node; m_initialPlayer = p_initialPlayer; m_cancelled = false; + m_dismissing = false; LoadValues(); Fit(); @@ -194,17 +199,114 @@ void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) } } -void OutcomeEditorPopup::OnDismiss() { m_node = nullptr; } +void OutcomeEditorPopup::OnDismiss() +{ + if (m_dismissing) { + return; + } + + if (m_cancelled) { + m_cancelled = false; + m_node = nullptr; + return; + } + + Commit(); +} void OutcomeEditorPopup::OnKeyDown(wxKeyEvent &p_event) { - if (p_event.GetKeyCode() == WXK_ESCAPE) { - m_cancelled = true; - Dismiss(); + switch (p_event.GetKeyCode()) { + case WXK_ESCAPE: + Cancel(); + return; + + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + Commit(); return; + + default: + p_event.Skip(); } +} - p_event.Skip(); +void OutcomeEditorPopup::Cancel() +{ + if (!m_node) { + return; + } + + m_cancelled = true; + Dismiss(); +} + +bool OutcomeEditorPopup::Commit() +{ + if (!m_node || !m_node->GetOutcome()) { + return false; + } + + const GameOutcome outcome = m_node->GetOutcome(); + const Game game = m_doc->GetGame(); + + std::vector payoffs; + payoffs.reserve(m_payoffCtrls.size()); + + for (auto *ctrl : m_payoffCtrls) { + wxString value = ctrl->GetValue(); + + if (value.EndsWith(wxT("/"))) { + value.RemoveLast(); + } + + try { + lexical_cast(value.ToStdString()); + } + catch (const std::exception &) { + RestoreAfterFailedCommit(ctrl); + return false; + } + + payoffs.push_back(value); + } + + try { + const wxString label = m_labelCtrl->GetValue(); + + // + // This document operation is introduced below. + // + m_doc->DoSetOutcomeData(outcome, label, payoffs); + } + catch (const std::exception &ex) { + ExceptionDialog(m_owner, ex.what()).ShowModal(); + return false; + } + + m_dismissing = true; + Dismiss(); + m_dismissing = false; + m_node = nullptr; + + return true; +} + +void OutcomeEditorPopup::RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl) +{ + wxBell(); + + CallAfter([this, p_invalidCtrl]() { + if (!m_node) { + return; + } + + PositionPopup(); + Popup(); + + p_invalidCtrl->SetFocus(); + p_invalidCtrl->SelectAll(); + }); } //-------------------------------------------------------------------------- @@ -737,8 +839,13 @@ void EfgDisplay::OnAcceptPayoffEdit(wxCommandEvent &) void EfgDisplay::PostPendingChanges() { - // FIXME: Save edit! - m_payoffEditor->EndEdit(); + if (m_outcomeEditor->IsShown()) { + m_outcomeEditor->Commit(); + } + + if (m_payoffEditor->IsEditing()) { + m_payoffEditor->EndEdit(); + } } void EfgDisplay::OnUpdate() diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index 4d7d5b8d7..ac2dc4fa2 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -574,6 +574,50 @@ void GameDocument::DoSetOutcome(GameNode p_node, GameOutcome p_outcome) UpdateViews(GBT_DOC_MODIFIED_PAYOFFS); } +void GameDocument::DoSetOutcomeData(const GameOutcome &p_outcome, const wxString &p_label, + const std::vector &p_payoffs) +{ + if (!p_outcome) { + return; + } + + if (p_payoffs.size() != NumPlayers()) { + throw std::invalid_argument("Incorrect number of payoff values"); + } + + std::vector parsedPayoffs; + parsedPayoffs.reserve(p_payoffs.size()); + + for (const auto &value : p_payoffs) { + parsedPayoffs.push_back(lexical_cast(value.ToStdString())); + } + + const std::string label = p_label.ToStdString(); + + bool changed = p_outcome->GetLabel() != label; + + for (size_t player = 1; player <= NumPlayers(); ++player) { + if (p_outcome->GetPayoff(GetGame()->GetPlayer(player)) != + parsedPayoffs[player - 1]) { + changed = true; + break; + } + } + + if (!changed) { + return; + } + + p_outcome->SetLabel(label); + + for (size_t player = 1; player <= NumPlayers(); ++player) { + p_outcome->SetPayoff(GetGame()->GetPlayer(player), + Number(p_payoffs[player - 1].ToStdString())); + } + + UpdateViews(GBT_DOC_MODIFIED_PAYOFFS); +} + void GameDocument::DoRemoveOutcome(GameNode p_node) { if (!p_node || !p_node->GetOutcome()) { diff --git a/src/gui/gamedoc.h b/src/gui/gamedoc.h index 32f2e9e1e..fe559a2fd 100644 --- a/src/gui/gamedoc.h +++ b/src/gui/gamedoc.h @@ -278,6 +278,8 @@ class GameDocument { void DoNewOutcome(GameNode p_node); void DoNewOutcome(const PureStrategyProfile &p_profile); void DoSetOutcome(GameNode p_node, GameOutcome p_outcome); + void DoSetOutcomeData(const GameOutcome &p_outcome, const wxString &p_label, + const std::vector &p_payoffs); void DoRemoveOutcome(GameNode p_node); void DoCopyOutcome(GameNode p_node, GameOutcome p_outcome); void DoSetPayoff(GameOutcome p_outcome, int p_player, const wxString &p_value); From 58783763ea09bc3d5c4e008dab86e0f04e5ca5e1 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 17 Jun 2026 12:20:52 +0100 Subject: [PATCH 3/7] Create outcome in edit popup --- src/gui/efgdisplay.cc | 56 ++++++++----------------------------------- src/gui/gamedoc.cc | 33 ++++++++++++++++--------- src/gui/gamedoc.h | 2 +- 3 files changed, 33 insertions(+), 58 deletions(-) diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index 639714868..72da40b97 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -142,7 +142,7 @@ void OutcomeEditorPopup::LoadValues() m_labelCtrl->Clear(); for (auto *ctrl : m_payoffCtrls) { - ctrl->Clear(); + ctrl->SetValue(wxT("0")); } return; @@ -243,13 +243,10 @@ void OutcomeEditorPopup::Cancel() bool OutcomeEditorPopup::Commit() { - if (!m_node || !m_node->GetOutcome()) { + if (!m_node) { return false; } - const GameOutcome outcome = m_node->GetOutcome(); - const Game game = m_doc->GetGame(); - std::vector payoffs; payoffs.reserve(m_payoffCtrls.size()); @@ -272,12 +269,7 @@ bool OutcomeEditorPopup::Commit() } try { - const wxString label = m_labelCtrl->GetValue(); - - // - // This document operation is introduced below. - // - m_doc->DoSetOutcomeData(outcome, label, payoffs); + m_doc->DoSetOutcomeData(m_node, m_labelCtrl->GetValue(), payoffs); } catch (const std::exception &ex) { ExceptionDialog(m_owner, ex.what()).ShowModal(); @@ -1174,34 +1166,16 @@ void EfgDisplay::OnLeftDoubleClick(wxMouseEvent &p_event) node = m_layout.OutcomeHitTest(x, y); if (node) { - if (!node->GetOutcome()) { - // Retain the existing behaviour temporarily for outcomes which - // have not yet been created. - m_doc->DoNewOutcome(node); - - wxClientDC dc(this); - PrepareDC(dc); - OnDraw(dc); + int initialPlayer = 0; + if (node->GetOutcome()) { auto entry = m_layout.GetNodeEntry(node); - const wxRect rect = entry->GetPayoffExtent(1); - - int xx, yy; - CalcScrolledPosition(LayoutToDevice(rect.x - 3), LayoutToDevice(rect.y - 3), &xx, &yy); - const int width = LayoutToDevice(rect.width + 10); - const int height = LayoutToDevice(rect.height + 6); - m_payoffEditor->SetSize(xx, yy, width, height); - m_payoffEditor->BeginEdit(entry, 1); - return; - } - int initialPlayer = 0; - - auto entry = m_layout.GetNodeEntry(node); - for (size_t player = 1; player <= m_doc->NumPlayers(); ++player) { - if (entry->GetPayoffExtent(player).Contains(x, y)) { - initialPlayer = static_cast(player); - break; + for (size_t player = 1; player <= m_doc->NumPlayers(); ++player) { + if (entry->GetPayoffExtent(player).Contains(x, y)) { + initialPlayer = static_cast(player); + break; + } } } @@ -1209,16 +1183,6 @@ void EfgDisplay::OnLeftDoubleClick(wxMouseEvent &p_event) return; } - if (m_doc->GetStyle().GetBranchAboveLabel() == GBT_BRANCH_LABEL_LABEL) { - node = m_layout.BranchAboveHitTest(x, y); - if (node) { - m_doc->SetSelectNode(node); - const wxCommandEvent event(wxEVT_COMMAND_MENU_SELECTED, GBT_MENU_EDIT_MOVE); - wxPostEvent(this, event); - return; - } - } - if (m_doc->GetStyle().GetBranchBelowLabel() == GBT_BRANCH_LABEL_LABEL) { node = m_layout.BranchBelowHitTest(x, y); if (node) { diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index ac2dc4fa2..676be4aa4 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -574,10 +574,10 @@ void GameDocument::DoSetOutcome(GameNode p_node, GameOutcome p_outcome) UpdateViews(GBT_DOC_MODIFIED_PAYOFFS); } -void GameDocument::DoSetOutcomeData(const GameOutcome &p_outcome, const wxString &p_label, +void GameDocument::DoSetOutcomeData(const GameNode &p_node, const wxString &p_label, const std::vector &p_payoffs) { - if (!p_outcome) { + if (!p_node) { return; } @@ -593,14 +593,21 @@ void GameDocument::DoSetOutcomeData(const GameOutcome &p_outcome, const wxString } const std::string label = p_label.ToStdString(); + GameOutcome outcome = p_node->GetOutcome(); - bool changed = p_outcome->GetLabel() != label; + bool changed = !outcome; - for (size_t player = 1; player <= NumPlayers(); ++player) { - if (p_outcome->GetPayoff(GetGame()->GetPlayer(player)) != - parsedPayoffs[player - 1]) { - changed = true; - break; + if (outcome) { + changed = outcome->GetLabel() != label; + + if (!changed) { + for (size_t player = 1; player <= NumPlayers(); ++player) { + if (outcome->GetPayoff(GetGame()->GetPlayer(player)) != + parsedPayoffs[player - 1]) { + changed = true; + break; + } + } } } @@ -608,11 +615,15 @@ void GameDocument::DoSetOutcomeData(const GameOutcome &p_outcome, const wxString return; } - p_outcome->SetLabel(label); + if (!outcome) { + outcome = GetGame()->NewOutcome(); + GetGame()->SetOutcome(p_node, outcome); + } + + outcome->SetLabel(label); for (size_t player = 1; player <= NumPlayers(); ++player) { - p_outcome->SetPayoff(GetGame()->GetPlayer(player), - Number(p_payoffs[player - 1].ToStdString())); + outcome->SetPayoff(GetGame()->GetPlayer(player), Number(p_payoffs[player - 1].ToStdString())); } UpdateViews(GBT_DOC_MODIFIED_PAYOFFS); diff --git a/src/gui/gamedoc.h b/src/gui/gamedoc.h index fe559a2fd..442b42936 100644 --- a/src/gui/gamedoc.h +++ b/src/gui/gamedoc.h @@ -278,7 +278,7 @@ class GameDocument { void DoNewOutcome(GameNode p_node); void DoNewOutcome(const PureStrategyProfile &p_profile); void DoSetOutcome(GameNode p_node, GameOutcome p_outcome); - void DoSetOutcomeData(const GameOutcome &p_outcome, const wxString &p_label, + void DoSetOutcomeData(const GameNode &p_node, const wxString &p_label, const std::vector &p_payoffs); void DoRemoveOutcome(GameNode p_node); void DoCopyOutcome(GameNode p_node, GameOutcome p_outcome); From 699a77f05409e2461d9427a47ebba1584e7eb59c Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 17 Jun 2026 12:23:38 +0100 Subject: [PATCH 4/7] Remove old payoff editor --- src/gui/efgdisplay.cc | 115 ------------------------------------------ src/gui/efgdisplay.h | 28 ---------- 2 files changed, 143 deletions(-) diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index 72da40b97..579844162 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -301,50 +301,6 @@ void OutcomeEditorPopup::RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl) }); } -//-------------------------------------------------------------------------- -// class TreePayoffEditor -//-------------------------------------------------------------------------- - -BEGIN_EVENT_TABLE(TreePayoffEditor, wxTextCtrl) -EVT_CHAR(TreePayoffEditor::OnChar) -END_EVENT_TABLE() - -TreePayoffEditor::TreePayoffEditor(wxWindow *p_parent) - : wxTextCtrl(p_parent, wxID_ANY, wxT(""), wxDefaultPosition, wxDefaultSize, wxTE_PROCESS_ENTER) -{ - wxWindowBase::SetValidator(NumberValidator(nullptr)); - wxWindow::Show(false); -} - -void TreePayoffEditor::BeginEdit(const std::shared_ptr &p_entry, int p_player) -{ - m_entry = p_entry; - m_outcome = p_entry->GetNode()->GetOutcome(); - m_player = p_player; - SetValue(wxString( - m_outcome->GetPayoff(p_entry->GetNode()->GetGame()->GetPlayer(p_player)) - .c_str(), - *wxConvCurrent)); - SetSize(wxSize(GetSize().GetWidth(), GetBestSize().GetHeight())); - SetSelection(-1, -1); - Show(true); - SetFocus(); -} - -void TreePayoffEditor::EndEdit() { Show(false); } - -void TreePayoffEditor::OnChar(wxKeyEvent &p_event) -{ - if (p_event.GetKeyCode() == WXK_TAB) { - // We handle the event and pass it to the parent - wxPostEvent(GetParent(), p_event); - } - else { - // Default processing - p_event.Skip(); - } -} - //-------------------------------------------------------------------------- // Bitmap drawing functions //-------------------------------------------------------------------------- @@ -589,16 +545,12 @@ END_EVENT_TABLE() EfgDisplay::EfgDisplay(wxWindow *p_parent, GameDocument *p_doc) : wxScrolledWindow(p_parent), GameView(p_doc), m_layout(p_doc), m_zoom(100), - m_payoffEditor(new TreePayoffEditor(this)), m_outcomeEditor(new OutcomeEditorPopup(this, p_doc)) { wxWindow::SetBackgroundColour(wxColour(250, 250, 250)); wxWindow::SetDropTarget(new PlayerDropTarget(this)); MakeMenus(); - - Connect(m_payoffEditor->GetId(), wxEVT_COMMAND_TEXT_ENTER, - wxCommandEventHandler(EfgDisplay::OnAcceptPayoffEdit)); OnUpdate(); } @@ -702,52 +654,6 @@ void EfgDisplay::OnKeyEvent(wxKeyEvent &p_event) return; } - if (m_payoffEditor->IsEditing()) { - if (p_event.GetKeyCode() == WXK_ESCAPE) { - m_payoffEditor->EndEdit(); - return; - } - if (p_event.GetKeyCode() == WXK_TAB) { - m_payoffEditor->EndEdit(); - - const GameOutcome outcome = m_payoffEditor->GetOutcome(); - const int player = m_payoffEditor->GetPlayer(); - const GameNode node = m_payoffEditor->GetNodeEntry()->GetNode(); - try { - m_doc->DoSetPayoff(outcome, player, m_payoffEditor->GetValue()); - } - catch (ValueException &) { - // For the moment, we will just silently discard edits which - // give payoffs that are not valid numbers - return; - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - return; - } - - // When we update views, the node entries get redone... - // Payoff rectangles are actually set during drawing, so - // force a refresh - wxClientDC dc(this); - PrepareDC(dc); - OnDraw(dc); - - if (player < static_cast(m_doc->NumPlayers())) { - auto entry = m_layout.GetNodeEntry(node); - const wxRect rect = entry->GetPayoffExtent(player + 1); - int xx, yy; - CalcScrolledPosition(LayoutToDevice(rect.x - 3), LayoutToDevice(rect.y - 3), &xx, &yy); - const int width = LayoutToDevice(rect.width + 10); - const int height = LayoutToDevice(rect.height + 6); - m_payoffEditor->SetSize(xx, yy, width, height); - m_payoffEditor->BeginEdit(entry, player + 1); - } - - return; - } - } - // After this point, all events involve moving relative to selected node. // So if there isn't a selected node, the event doesn't apply const GameNode selectNode = m_doc->GetSelectNode(); @@ -808,23 +714,6 @@ void EfgDisplay::OnKeyEvent(wxKeyEvent &p_event) } } -void EfgDisplay::OnAcceptPayoffEdit(wxCommandEvent &) -{ - const GameOutcome outcome = m_payoffEditor->GetOutcome(); - const int player = m_payoffEditor->GetPlayer(); - wxString value = m_payoffEditor->GetValue(); - if (value.EndsWith(_T("/"))) { - value = value.Left(value.length() - 1); - } - try { - m_doc->DoSetPayoff(outcome, player, value); - m_payoffEditor->EndEdit(); - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } -} - //--------------------------------------------------------------------- // EfgDisplay: Implementing GameView members //--------------------------------------------------------------------- @@ -834,10 +723,6 @@ void EfgDisplay::PostPendingChanges() if (m_outcomeEditor->IsShown()) { m_outcomeEditor->Commit(); } - - if (m_payoffEditor->IsEditing()) { - m_payoffEditor->EndEdit(); - } } void EfgDisplay::OnUpdate() diff --git a/src/gui/efgdisplay.h b/src/gui/efgdisplay.h index df8dacd89..899cf26c2 100644 --- a/src/gui/efgdisplay.h +++ b/src/gui/efgdisplay.h @@ -30,36 +30,10 @@ namespace Gambit::GUI { class OutcomeEditorPopup; -class TreePayoffEditor final : public wxTextCtrl { - std::shared_ptr m_entry{nullptr}; - GameOutcome m_outcome; - int m_player{0}; - - /// @name Event handlers - //@{ - void OnChar(wxKeyEvent &); - //@} - -public: - explicit TreePayoffEditor(wxWindow *p_parent); - - void BeginEdit(const std::shared_ptr &p_node, int p_player); - void EndEdit(); - - bool IsEditing() const { return IsShown(); } - - std::shared_ptr GetNodeEntry() const { return m_entry; } - GameOutcome GetOutcome() const { return m_outcome; } - int GetPlayer() const { return m_player; } - - DECLARE_EVENT_TABLE() -}; - class EfgDisplay final : public wxScrolledWindow, public GameView { TreeLayout m_layout; int m_zoom; wxMenu *m_nodeMenu{nullptr}; - TreePayoffEditor *m_payoffEditor; OutcomeEditorPopup *m_outcomeEditor; bool m_pendingInitialZoom{true}; @@ -75,8 +49,6 @@ class EfgDisplay final : public wxScrolledWindow, public GameView { void OnLeftDoubleClick(wxMouseEvent &); void OnMagnify(wxMouseEvent &); void OnKeyEvent(wxKeyEvent &); - /// Payoff editor changes accepted with enter - void OnAcceptPayoffEdit(wxCommandEvent &); //@} /// @name Overriding GameView members From 159c49aefb086f0496fa7f8cf59e1d5fc8d69e9c Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 17 Jun 2026 12:31:15 +0100 Subject: [PATCH 5/7] Some styling of new outcome editor popup --- src/gui/efgdisplay.cc | 57 ++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index 579844162..01d7689f6 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -62,6 +62,7 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { GameNode m_node; + wxPanel *m_contentPanel; wxTextCtrl *m_labelCtrl; wxFlexGridSizer *m_gridSizer; std::vector m_payoffCtrls; @@ -72,10 +73,11 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { }; OutcomeEditorPopup::OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc) - : wxPopupTransientWindow(p_owner, wxBORDER_SIMPLE), m_owner(p_owner), m_doc(p_doc), - m_labelCtrl(nullptr), m_gridSizer(nullptr) + : wxPopupTransientWindow(p_owner, wxBORDER_NONE), m_owner(p_owner), m_doc(p_doc), + m_contentPanel(nullptr), m_labelCtrl(nullptr) { - SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + BuildControls(); Bind(wxEVT_CHAR_HOOK, &OutcomeEditorPopup::OnKeyDown, this); @@ -83,33 +85,43 @@ OutcomeEditorPopup::OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc) void OutcomeEditorPopup::BuildControls() { + auto *popupSizer = new wxBoxSizer(wxVERTICAL); + + m_contentPanel = new wxPanel(this); + m_contentPanel->SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + auto *outerSizer = new wxBoxSizer(wxVERTICAL); - auto *heading = new wxStaticText(this, wxID_ANY, _("Outcome")); + auto *heading = new wxStaticText(m_contentPanel, wxID_ANY, _("Outcome")); + wxFont headingFont = heading->GetFont(); headingFont.SetWeight(wxFONTWEIGHT_BOLD); + headingFont.SetPointSize(headingFont.GetPointSize() + 1); heading->SetFont(headingFont); - outerSizer->Add(heading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); + outerSizer->Add(heading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); - m_gridSizer = new wxFlexGridSizer(2, FromDIP(6), FromDIP(10)); - m_gridSizer->AddGrowableCol(1, 1); + auto *labelSizer = new wxFlexGridSizer(2, FromDIP(7), FromDIP(12)); + labelSizer->AddGrowableCol(1, 1); - m_gridSizer->Add(new wxStaticText(this, wxID_ANY, _("Label")), 0, wxALIGN_CENTER_VERTICAL); + labelSizer->Add(new wxStaticText(m_contentPanel, wxID_ANY, _("Label")), 0, + wxALIGN_CENTER_VERTICAL); - m_labelCtrl = new wxTextCtrl(this, wxID_ANY); + m_labelCtrl = new wxTextCtrl(m_contentPanel, wxID_ANY); m_labelCtrl->SetMinSize(wxSize(FromDIP(180), -1)); - m_gridSizer->Add(m_labelCtrl, 1, wxEXPAND); + labelSizer->Add(m_labelCtrl, 1, wxEXPAND); + + outerSizer->Add(labelSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); + + auto *payoffHeading = new wxStaticText(m_contentPanel, wxID_ANY, _("Payoffs")); - auto *payoffHeading = new wxStaticText(this, wxID_ANY, _("Payoffs")); wxFont payoffHeadingFont = payoffHeading->GetFont(); payoffHeadingFont.SetWeight(wxFONTWEIGHT_BOLD); payoffHeading->SetFont(payoffHeadingFont); - outerSizer->Add(m_gridSizer, 0, wxEXPAND | wxALL, FromDIP(10)); - outerSizer->Add(payoffHeading, 0, wxLEFT | wxRIGHT, FromDIP(10)); + outerSizer->Add(payoffHeading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); - auto *payoffSizer = new wxFlexGridSizer(2, FromDIP(6), FromDIP(10)); + auto *payoffSizer = new wxFlexGridSizer(2, FromDIP(7), FromDIP(12)); payoffSizer->AddGrowableCol(1, 1); const Game game = m_doc->GetGame(); @@ -117,11 +129,12 @@ void OutcomeEditorPopup::BuildControls() for (size_t player = 1; player <= m_doc->NumPlayers(); ++player) { const GamePlayer gamePlayer = game->GetPlayer(player); - payoffSizer->Add( - new wxStaticText(this, wxID_ANY, wxString(gamePlayer->GetLabel().c_str(), *wxConvCurrent)), - 0, wxALIGN_CENTER_VERTICAL); + payoffSizer->Add(new wxStaticText(m_contentPanel, wxID_ANY, + wxString(gamePlayer->GetLabel().c_str(), *wxConvCurrent)), + 0, wxALIGN_CENTER_VERTICAL); + + auto *payoffCtrl = new wxTextCtrl(m_contentPanel, wxID_ANY); - auto *payoffCtrl = new wxTextCtrl(this, wxID_ANY); payoffCtrl->SetValidator(NumberValidator(nullptr)); payoffCtrl->SetMinSize(wxSize(FromDIP(100), -1)); @@ -129,9 +142,13 @@ void OutcomeEditorPopup::BuildControls() m_payoffCtrls.push_back(payoffCtrl); } - outerSizer->Add(payoffSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(10)); + outerSizer->Add(payoffSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, FromDIP(12)); + + m_contentPanel->SetSizer(outerSizer); + + popupSizer->Add(m_contentPanel, 1, wxEXPAND | wxALL, FromDIP(1)); - SetSizerAndFit(outerSizer); + SetSizerAndFit(popupSizer); } void OutcomeEditorPopup::LoadValues() From cd98ad5ec0f3fcb088708e74a710457645f98855 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 17 Jun 2026 12:36:15 +0100 Subject: [PATCH 6/7] Update docs to mention editing panel --- doc/gui.efg.rst | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/doc/gui.efg.rst b/doc/gui.efg.rst index a863bdb1e..c63c7cfdb 100644 --- a/doc/gui.efg.rst +++ b/doc/gui.efg.rst @@ -193,18 +193,12 @@ a :guilabel:`(u)` in light grey to the right of a node. To set the payoffs at a node, double-click on the :guilabel:`(u)` to the right of the node. This creates a new outcome at the node, with payoffs of -zero for all players, and displays an editor to set the payoff of the -first player. - -The payoff to a player for an outcome can be edited by double-clicking -on the payoff entry. This action creates a text edit control in which -the payoff to that player can be modified. Edits to the payoff can be -accepted by pressing the :kbd:`Enter` key. In addition, accepting the -payoff by pressing the :kbd:`Tab` key both stores the changes to the -player's payoff, and advances the editor to the payoff for the next -player at that outcome. - -Outcomes may also be moved or copied using a drag-and-drop idiom. +zero for all players, and pops up an editing panel for that outcome. + +For nodes with existing outcomes, clicking on any of the displayed +payoffs pops up an editing panel for that outcome. + +Outcomes may also be moved or copied using drag-and-drop. Left-clicking and dragging an outcome to another node moves the outcome from the original node to the target node. Copying an outcome may be accomplished by doing this same action while holding down the From 8446e6ed681580fb71caaa05a8c8ca6a456a0102 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 18 Jun 2026 16:52:58 +0100 Subject: [PATCH 7/7] Update ChangeLog --- ChangeLog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ChangeLog b/ChangeLog index f3f0bdca4..08a0ee71a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -36,6 +36,8 @@ unsaved work. (#12) - Created and documented right-click context menu in the normal form pivot table, which makes the mechanism for deleting a strategy more clear. (#855) +- Payoff editing in extensive games in the graphical interface is now done via a context popup window + rather than text controls drawn (not always well!) over the game tree display. (#947) - In `pygambit`, indexing game object collections by integer position has been removed. (#942) ### Removed