From 49543529f76696d1149b14f5b91a6d66909d4728 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 29 Jun 2026 15:28:33 +0100 Subject: [PATCH 01/14] Set up label editing UX, apply to action/infoset label editing. --- Makefile.am | 2 + src/gui/dleditmove.cc | 29 +++++---- src/gui/dleditmove.h | 8 ++- src/gui/editlabel.cc | 145 ++++++++++++++++++++++++++++++++++++++++++ src/gui/editlabel.h | 61 ++++++++++++++++++ src/gui/gameframe.cc | 4 +- 6 files changed, 231 insertions(+), 18 deletions(-) create mode 100644 src/gui/editlabel.cc create mode 100644 src/gui/editlabel.h diff --git a/Makefile.am b/Makefile.am index 5ef18af9b..f975be541 100644 --- a/Makefile.am +++ b/Makefile.am @@ -471,6 +471,8 @@ gambit_SOURCES = \ src/gui/analysis.h \ src/gui/app.cc \ src/gui/app.h \ + src/gui/editlabel.cc \ + src/gui/editlabel.h \ src/gui/edittext.cc \ src/gui/edittext.h \ src/gui/dlabout.cc \ diff --git a/src/gui/dleditmove.cc b/src/gui/dleditmove.cc index 9bfdf0ea6..3dbfc9de5 100644 --- a/src/gui/dleditmove.cc +++ b/src/gui/dleditmove.cc @@ -30,20 +30,21 @@ #include "gambit.h" #include "dleditmove.h" #include "valnumber.h" +#include "editlabel.h" namespace Gambit::GUI { class ActionPanel final : public wxScrolledWindow { std::vector m_actionProbValues; - std::vector m_actionNames; + std::vector m_actionLabels; std::vector m_actionProbs; public: ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset); - int NumActions() const { return static_cast(m_actionNames.size()); } + int NumActions() const { return static_cast(m_actionLabels.size()); } - wxString GetActionName(int p_act) const; + wxString GetActionLabel(int p_act) const; Array GetActionProbs() const; }; @@ -57,7 +58,7 @@ ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) m_actionProbValues.reserve(p_infoset->GetActions().size()); m_actionProbs.reserve(p_infoset->GetActions().size()); } - m_actionNames.reserve(p_infoset->GetActions().size()); + m_actionLabels.reserve(p_infoset->GetActions().size()); const int numColumns = isChance ? 3 : 2; @@ -82,8 +83,9 @@ ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) wxALIGN_CENTER_VERTICAL | wxALIGN_RIGHT); auto *name = - new wxTextCtrl(this, wxID_ANY, wxString(action->GetLabel().c_str(), *wxConvCurrent)); - m_actionNames.push_back(name); + new LabelTextCtrl(this, wxID_ANY, wxString(action->GetLabel().c_str(), *wxConvCurrent), + LabelCharacterPolicy::AsciiOnly); + m_actionLabels.push_back(name); gridSizer->Add(name, 1, wxEXPAND); if (isChance) { @@ -110,9 +112,9 @@ ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) SetMinSize(wxSize(FromDIP(isChance ? 400 : 300), std::min(bestSize.GetHeight(), FromDIP(250)))); } -wxString ActionPanel::GetActionName(int p_act) const +wxString ActionPanel::GetActionLabel(int p_act) const { - return m_actionNames.at(p_act - 1)->GetValue(); + return m_actionLabels.at(p_act - 1)->GetNormalizedValue(); } Array ActionPanel::GetActionProbs() const @@ -138,9 +140,10 @@ EditMoveDialog::EditMoveDialog(wxWindow *p_parent, const GameInfoset &p_infoset) auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); labelSizer->Add(new wxStaticText(this, wxID_STATIC, _("Information set label")), 0, wxALL | wxALIGN_CENTER_VERTICAL, 5); - m_infosetName = - new wxTextCtrl(this, wxID_ANY, wxString(p_infoset->GetLabel().c_str(), *wxConvCurrent)); - labelSizer->Add(m_infosetName, 1, wxALL | wxEXPAND, 5); + m_infosetLabel = + new LabelTextCtrl(this, wxID_ANY, wxString(p_infoset->GetLabel().c_str(), *wxConvCurrent), + LabelCharacterPolicy::AsciiOnly); + labelSizer->Add(m_infosetLabel, 1, wxALL | wxEXPAND, 5); topSizer->Add(labelSizer, 0, wxEXPAND); { @@ -215,9 +218,9 @@ void EditMoveDialog::OnOK(wxCommandEvent &p_event) int EditMoveDialog::NumActions() const { return m_actionPanel->NumActions(); } -wxString EditMoveDialog::GetActionName(int p_act) const +wxString EditMoveDialog::GetActionLabel(int p_act) const { - return m_actionPanel->GetActionName(p_act); + return m_actionPanel->GetActionLabel(p_act); } Array EditMoveDialog::GetActionProbs() const { return m_actionPanel->GetActionProbs(); } diff --git a/src/gui/dleditmove.h b/src/gui/dleditmove.h index 26aeb9c45..07f90ae76 100644 --- a/src/gui/dleditmove.h +++ b/src/gui/dleditmove.h @@ -23,13 +23,15 @@ #ifndef GAMBIT_GUI_DLEDITMOVE_H #define GAMBIT_GUI_DLEDITMOVE_H +#include "editlabel.h" + namespace Gambit::GUI { class ActionPanel; class EditMoveDialog final : public wxDialog { GameInfoset m_infoset; wxChoice *m_player; - wxTextCtrl *m_infosetName; + LabelTextCtrl *m_infosetLabel; ActionPanel *m_actionPanel; void OnOK(wxCommandEvent &); @@ -39,11 +41,11 @@ class EditMoveDialog final : public wxDialog { EditMoveDialog(wxWindow *p_parent, const GameInfoset &p_infoset); // Data access (only valid when ShowModal() returns with wxID_OK) - wxString GetInfosetName() const { return m_infosetName->GetValue(); } + wxString GetInfosetLabel() const { return m_infosetLabel->GetNormalizedValue(); } int GetPlayer() const { return (m_player->GetSelection() + 1); } int NumActions() const; - wxString GetActionName(int p_act) const; + wxString GetActionLabel(int p_act) const; Array GetActionProbs() const; }; } // namespace Gambit::GUI diff --git a/src/gui/editlabel.cc b/src/gui/editlabel.cc new file mode 100644 index 000000000..4a1ddbf3a --- /dev/null +++ b/src/gui/editlabel.cc @@ -0,0 +1,145 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/editlabel.cc +// Text control for editing valid labels +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +// + +#include +#ifndef WX_PRECOMP +#include +#endif // WX_PRECOMP + +#include + +#include "editlabel.h" + +namespace Gambit::GUI { + +bool LabelTextCtrl::IsAsciiPrintable(wxUniChar p_char) +{ + const auto value = static_cast(p_char); + return value >= 0x20 && value <= 0x7e; +} + +bool LabelTextCtrl::IsLabelWhitespace(wxUniChar p_char) +{ + return p_char == ' ' || p_char == '\t' || p_char == '\r' || p_char == '\n' || p_char == '\v' || + p_char == '\f'; +} + +bool LabelTextCtrl::IsAllowedNonWhitespace(wxUniChar p_char) const +{ + switch (m_policy) { + case LabelCharacterPolicy::AsciiOnly: + return IsAsciiPrintable(p_char) && !IsLabelWhitespace(p_char); + + case LabelCharacterPolicy::Unicode: + return !IsLabelWhitespace(p_char); + + default: + return false; + } +} + +wxString LabelTextCtrl::Normalize(const wxString &p_value, bool p_stripTrailing) const +{ + wxString normalized; + bool sawNonWhitespace = false; + bool previousWasSpace = false; + + for (wxString::const_iterator iter = p_value.begin(); iter != p_value.end(); ++iter) { + const wxUniChar ch = *iter; + + if (IsLabelWhitespace(ch)) { + if (!sawNonWhitespace) { + continue; + } + if (!previousWasSpace) { + normalized << ' '; + previousWasSpace = true; + } + continue; + } + + if (!IsAllowedNonWhitespace(ch)) { + continue; + } + + normalized << ch; + sawNonWhitespace = true; + previousWasSpace = false; + } + + if (p_stripTrailing && normalized.EndsWith(" ")) { + normalized.RemoveLast(); + } + + return normalized; +} + +void LabelTextCtrl::NormalizeInPlace(bool p_stripTrailing) +{ + if (m_normalizing) { + return; + } + + const wxString oldValue = GetValue(); + const wxString newValue = Normalize(oldValue, p_stripTrailing); + if (oldValue == newValue) { + return; + } + + const long insertionPoint = GetInsertionPoint(); + + m_normalizing = true; + ChangeValue(newValue); + SetInsertionPoint(std::min(insertionPoint, newValue.length())); + m_normalizing = false; +} + +void LabelTextCtrl::OnText(wxCommandEvent &p_event) +{ + NormalizeInPlace(false); + p_event.Skip(); +} + +void LabelTextCtrl::OnKillFocus(wxFocusEvent &p_event) +{ + NormalizeInPlace(true); + p_event.Skip(); +} + +LabelTextCtrl::LabelTextCtrl(wxWindow *p_parent, wxWindowID p_id, const wxString &p_value, + LabelCharacterPolicy p_policy, const wxPoint &p_pos, + const wxSize &p_size, long p_style) + : wxTextCtrl(p_parent, p_id, wxEmptyString, p_pos, p_size, p_style), m_policy(p_policy) +{ + ChangeValue(Normalize(p_value, true)); + + Bind(wxEVT_TEXT, &LabelTextCtrl::OnText, this); + Bind(wxEVT_KILL_FOCUS, &LabelTextCtrl::OnKillFocus, this); +} + +wxString LabelTextCtrl::GetNormalizedValue() +{ + NormalizeInPlace(true); + return GetValue(); +} + +} // namespace Gambit::GUI diff --git a/src/gui/editlabel.h b/src/gui/editlabel.h new file mode 100644 index 000000000..95ddf62fa --- /dev/null +++ b/src/gui/editlabel.h @@ -0,0 +1,61 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/editlabel.h +// Text control for editing valid labels +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +// + +#ifndef EDITLABEL_H +#define EDITLABEL_H + +#include +#ifndef WX_PRECOMP +#include +#endif // WX_PRECOMP + +namespace Gambit::GUI { + +enum class LabelCharacterPolicy { AsciiOnly, Unicode }; + +class LabelTextCtrl final : public wxTextCtrl { + LabelCharacterPolicy m_policy; + bool m_normalizing{false}; + + static bool IsAsciiPrintable(wxUniChar p_char); + static bool IsLabelWhitespace(wxUniChar p_char); + + bool IsAllowedNonWhitespace(wxUniChar p_char) const; + + wxString Normalize(const wxString &p_value, bool p_stripTrailing) const; + void NormalizeInPlace(bool p_stripTrailing); + + void OnText(wxCommandEvent &p_event); + void OnKillFocus(wxFocusEvent &p_event); + +public: + LabelTextCtrl(wxWindow *p_parent, wxWindowID p_id, const wxString &p_value, + LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly, + const wxPoint &p_pos = wxDefaultPosition, const wxSize &p_size = wxDefaultSize, + long p_style = 0); + + wxString GetNormalizedValue(); +}; + +} // namespace Gambit::GUI + +#endif // EDITLABEL_H diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index 7b27cf358..48749efa3 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -1029,14 +1029,14 @@ void GameFrame::OnEditMove(wxCommandEvent &) EditMoveDialog dialog(this, infoset); if (dialog.ShowModal() == wxID_OK) { try { - m_doc->DoSetInfosetLabel(infoset, dialog.GetInfosetName()); + m_doc->DoSetInfosetLabel(infoset, dialog.GetInfosetLabel()); if (!infoset->IsChanceInfoset() && dialog.GetPlayer() != infoset->GetPlayer()->GetNumber()) { m_doc->DoSetPlayer(infoset, m_doc->GetGame()->GetPlayer(dialog.GetPlayer())); } for (const auto &action : infoset->GetActions()) { - m_doc->DoSetActionLabel(action, dialog.GetActionName(action->GetNumber())); + m_doc->DoSetActionLabel(action, dialog.GetActionLabel(action->GetNumber())); } if (infoset->IsChanceInfoset()) { m_doc->DoSetActionProbs(infoset, dialog.GetActionProbs()); From 1ff96fb762633cd6f15661a282f0749a133fb215 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 1 Jul 2026 09:59:01 +0100 Subject: [PATCH 02/14] UX on node label editing for valid labels --- src/gui/dleditnode.cc | 6 +++--- src/gui/dleditnode.h | 6 ++++-- src/gui/gameframe.cc | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/gui/dleditnode.cc b/src/gui/dleditnode.cc index 7613a4a6f..126e0504b 100644 --- a/src/gui/dleditnode.cc +++ b/src/gui/dleditnode.cc @@ -39,9 +39,9 @@ EditNodeDialog::EditNodeDialog(wxWindow *p_parent, const GameNode &p_node) auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); labelSizer->Add(new wxStaticText(this, wxID_STATIC, _("Node label")), 0, wxALL | wxCENTER, 5); - m_nodeName = - new wxTextCtrl(this, wxID_ANY, wxString(m_node->GetLabel().c_str(), *wxConvCurrent)); - labelSizer->Add(m_nodeName, 1, wxALL | wxCENTER | wxEXPAND, 5); + m_nodeLabel = + new LabelTextCtrl(this, wxID_ANY, wxString(m_node->GetLabel().c_str(), *wxConvCurrent)); + labelSizer->Add(m_nodeLabel, 1, wxALL | wxCENTER | wxEXPAND, 5); topSizer->Add(labelSizer, 0, wxALL | wxEXPAND, 5); auto *infosetSizer = new wxBoxSizer(wxHORIZONTAL); diff --git a/src/gui/dleditnode.h b/src/gui/dleditnode.h index 9608c2c4f..b4af48204 100644 --- a/src/gui/dleditnode.h +++ b/src/gui/dleditnode.h @@ -23,10 +23,12 @@ #ifndef GAMBIT_GUI_DLEDITNODE_H #define GAMBIT_GUI_DLEDITNODE_H +#include "editlabel.h" + namespace Gambit::GUI { class EditNodeDialog final : public wxDialog { GameNode m_node; - wxTextCtrl *m_nodeName; + LabelTextCtrl *m_nodeLabel; wxChoice *m_outcome, *m_infoset; Array m_infosetList; @@ -35,7 +37,7 @@ class EditNodeDialog final : public wxDialog { EditNodeDialog(wxWindow *p_parent, const GameNode &p_node); // Data access (only valid when ShowModal() returns with wxID_OK) - wxString GetNodeName() const { return m_nodeName->GetValue(); } + wxString GetNodeLabel() const { return m_nodeLabel->GetValue(); } int GetOutcome() const { return m_outcome->GetSelection(); } GameInfoset GetInfoset() const; }; diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index 48749efa3..12362d0a1 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -994,7 +994,7 @@ void GameFrame::OnEditNode(wxCommandEvent &) EditNodeDialog dialog(this, m_doc->GetSelectNode()); if (dialog.ShowModal() == wxID_OK) { try { - m_doc->DoSetNodeLabel(m_doc->GetSelectNode(), dialog.GetNodeName()); + m_doc->DoSetNodeLabel(m_doc->GetSelectNode(), dialog.GetNodeLabel()); if (dialog.GetOutcome() > 0) { m_doc->DoSetOutcome(m_doc->GetSelectNode(), m_doc->GetGame()->GetOutcome(dialog.GetOutcome())); From 6c26e275237a9f485368f02c97762d49b1793fcd Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 1 Jul 2026 10:20:46 +0100 Subject: [PATCH 03/14] Add label UX in strategic form grids --- Makefile.am | 2 + src/gui/editlabel.cc | 9 ++-- src/gui/editlabel.h | 8 +-- src/gui/labelcell.cc | 115 +++++++++++++++++++++++++++++++++++++++++++ src/gui/labelcell.h | 54 ++++++++++++++++++++ src/gui/nfgtable.cc | 12 ++++- 6 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 src/gui/labelcell.cc create mode 100644 src/gui/labelcell.h diff --git a/Makefile.am b/Makefile.am index f975be541..573c3780b 100644 --- a/Makefile.am +++ b/Makefile.am @@ -475,6 +475,8 @@ gambit_SOURCES = \ src/gui/editlabel.h \ src/gui/edittext.cc \ src/gui/edittext.h \ + src/gui/labelcell.cc \ + src/gui/labelcell.h \ src/gui/dlabout.cc \ src/gui/dlabout.h \ src/gui/dleditmove.cc \ diff --git a/src/gui/editlabel.cc b/src/gui/editlabel.cc index 4a1ddbf3a..0bed480d5 100644 --- a/src/gui/editlabel.cc +++ b/src/gui/editlabel.cc @@ -43,9 +43,9 @@ bool LabelTextCtrl::IsLabelWhitespace(wxUniChar p_char) p_char == '\f'; } -bool LabelTextCtrl::IsAllowedNonWhitespace(wxUniChar p_char) const +bool LabelTextCtrl::IsAllowedNonWhitespace(wxUniChar p_char, LabelCharacterPolicy p_policy) { - switch (m_policy) { + switch (p_policy) { case LabelCharacterPolicy::AsciiOnly: return IsAsciiPrintable(p_char) && !IsLabelWhitespace(p_char); @@ -57,7 +57,8 @@ bool LabelTextCtrl::IsAllowedNonWhitespace(wxUniChar p_char) const } } -wxString LabelTextCtrl::Normalize(const wxString &p_value, bool p_stripTrailing) const +wxString LabelTextCtrl::Normalize(const wxString &p_value, bool p_stripTrailing, + LabelCharacterPolicy p_policy) { wxString normalized; bool sawNonWhitespace = false; @@ -77,7 +78,7 @@ wxString LabelTextCtrl::Normalize(const wxString &p_value, bool p_stripTrailing) continue; } - if (!IsAllowedNonWhitespace(ch)) { + if (!IsAllowedNonWhitespace(ch, p_policy)) { continue; } diff --git a/src/gui/editlabel.h b/src/gui/editlabel.h index 95ddf62fa..a0034163a 100644 --- a/src/gui/editlabel.h +++ b/src/gui/editlabel.h @@ -38,16 +38,18 @@ class LabelTextCtrl final : public wxTextCtrl { static bool IsAsciiPrintable(wxUniChar p_char); static bool IsLabelWhitespace(wxUniChar p_char); + static bool IsAllowedNonWhitespace(wxUniChar p_char, LabelCharacterPolicy p_policy); - bool IsAllowedNonWhitespace(wxUniChar p_char) const; - - wxString Normalize(const wxString &p_value, bool p_stripTrailing) const; + wxString NormalizeValue(const wxString &p_value, bool p_stripTrailing) const; void NormalizeInPlace(bool p_stripTrailing); void OnText(wxCommandEvent &p_event); void OnKillFocus(wxFocusEvent &p_event); public: + static wxString Normalize(const wxString &p_value, bool p_stripTrailing, + LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly); + LabelTextCtrl(wxWindow *p_parent, wxWindowID p_id, const wxString &p_value, LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly, const wxPoint &p_pos = wxDefaultPosition, const wxSize &p_size = wxDefaultSize, diff --git a/src/gui/labelcell.cc b/src/gui/labelcell.cc new file mode 100644 index 000000000..1c1a0abd4 --- /dev/null +++ b/src/gui/labelcell.cc @@ -0,0 +1,115 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/labelcell.cc +// Implementation of wxSheet editor for Gambit labels +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +// + +#ifndef WX_PRECOMP +#include +#endif // WX_PRECOMP + +#include "labelcell.h" + +#include "wx/sheet/sheet.h" + +namespace Gambit::GUI { + +IMPLEMENT_DYNAMIC_CLASS(LabelEditorRefData, wxSheetCellTextEditorRefData) + +LabelEditorRefData::LabelEditorRefData(LabelCharacterPolicy p_policy) : m_policy(p_policy) {} + +void LabelEditorRefData::CreateEditor(wxWindow *parent, wxWindowID id, wxEvtHandler *evtHandler, + wxSheet *sheet) +{ + auto *textCtrl = + new LabelTextCtrl(parent, id, wxEmptyString, m_policy, wxDefaultPosition, wxDefaultSize, + wxTE_PROCESS_TAB | wxTE_CENTER | wxBORDER_NONE); + SetControl(textCtrl); + + textCtrl->Bind(wxEVT_KILL_FOCUS, [sheet](wxFocusEvent &event) { + if (!sheet->IsTabTraversing()) { + sheet->CallAfter([sheet]() { + if (!sheet->IsTabTraversing() && sheet->IsCellEditControlShown()) { + sheet->DisableCellEditControl(true); + sheet->Refresh(); + } + }); + } + event.Skip(); + }); + + if (m_maxChars != 0) { + textCtrl->SetMaxLength(m_maxChars); + } + + wxSheetCellEditorRefData::CreateEditor(parent, id, evtHandler, sheet); +} + +bool LabelEditorRefData::Copy(const LabelEditorRefData &p_other) +{ + m_policy = p_other.m_policy; + return wxSheetCellTextEditorRefData::Copy(p_other); +} + +bool LabelEditorRefData::IsAcceptedKey(wxKeyEvent &p_event) +{ + if (!wxSheetCellEditorRefData::IsAcceptedKey(p_event)) { + return false; + } + + const int keycode = p_event.GetKeyCode(); + + // Let the editor start on ordinary printable ASCII characters. The + // LabelTextCtrl itself performs full normalization and filtering, so this + // does not need to duplicate the complete label policy. + if (m_policy == LabelCharacterPolicy::AsciiOnly) { + return keycode >= 0x20 && keycode <= 0x7e; + } + + // For the future Unicode policy, accept the key here and let LabelTextCtrl + // normalize/filter the resulting text. + return true; +} + +void LabelEditorRefData::StartingKey(wxKeyEvent &p_event) +{ + const int keycode = p_event.GetKeyCode(); + + if (m_policy == LabelCharacterPolicy::AsciiOnly && (keycode < 0x20 || keycode > 0x7e)) { + p_event.Skip(); + return; + } + + wxSheetCellTextEditorRefData::StartingKey(p_event); +} + +bool LabelEditorRefData::EndEdit(const wxSheetCoords &p_coords, wxSheet *p_sheet) +{ + auto *textCtrl = wxStaticCast(GetTextCtrl(), LabelTextCtrl); + const wxString value = textCtrl->GetNormalizedValue(); + + if (value == p_sheet->GetCellValue(p_coords)) { + return false; + } + + p_sheet->SetCellValue(p_coords, value); + return true; +} + +} // namespace Gambit::GUI diff --git a/src/gui/labelcell.h b/src/gui/labelcell.h new file mode 100644 index 000000000..0b4c8f995 --- /dev/null +++ b/src/gui/labelcell.h @@ -0,0 +1,54 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/labelcell.h +// Declaration of wxSheet editor for Gambit labels +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +// + +#ifndef GAMBIT_GUI_LABELCELLEDITOR_H +#define GAMBIT_GUI_LABELCELLEDITOR_H + +#include "wx/sheet/sheet.h" + +#include "editlabel.h" +#include "renratio.h" // for DECLARE_GAMBIT_SHEETOBJREFDATA_COPY_CLASS + +namespace Gambit::GUI { + +class LabelEditorRefData final : public wxSheetCellTextEditorRefData { + LabelCharacterPolicy m_policy{LabelCharacterPolicy::AsciiOnly}; + +public: + explicit LabelEditorRefData(LabelCharacterPolicy p_policy = LabelCharacterPolicy::AsciiOnly); + + void CreateEditor(wxWindow *, wxWindowID, wxEvtHandler *, wxSheet *) override; + + /// Override basic text editor behavior to normalize label editing. + bool IsAcceptedKey(wxKeyEvent &) override; + void StartingKey(wxKeyEvent &) override; + bool EndEdit(const wxSheetCoords &, wxSheet *) override; + + bool Copy(const LabelEditorRefData &p_other); + + // NOLINTNEXTLINE(modernize-use-auto) + DECLARE_GAMBIT_SHEETOBJREFDATA_COPY_CLASS(LabelEditorRefData, wxSheetCellTextEditorRefData) +}; + +} // namespace Gambit::GUI + +#endif // GAMBIT_GUI_LABELCELLEDITOR_H diff --git a/src/gui/nfgtable.cc b/src/gui/nfgtable.cc index 13dc0bc45..bf54b5c92 100644 --- a/src/gui/nfgtable.cc +++ b/src/gui/nfgtable.cc @@ -31,6 +31,8 @@ #include "wx/sheet/sheet.h" #include "renratio.h" // special renderer for rational numbers +#include "editlabel.h" +#include "labelcell.h" #include "gamedoc.h" #include "nfgpanel.h" @@ -240,7 +242,9 @@ wxString RowPlayerWidget::GetCellValue(const wxSheetCoords &p_coords) void RowPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) { - m_table->RenameRowHeaderStrategy(p_coords.GetCol(), p_coords.GetRow(), p_value); + m_table->RenameRowHeaderStrategy( + p_coords.GetCol(), p_coords.GetRow(), + LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); } wxSheetCellAttr RowPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetAttr_Type) const @@ -252,6 +256,7 @@ wxSheetCellAttr RowPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetA if (m_table->GetRowHeaderColCount() > 0) { attr.SetForegroundColour( m_table->GetPlayerColor(m_table->GetRowHeaderPlayer(p_coords.GetCol()))); + attr.SetEditor(wxSheetCellEditor(new LabelEditorRefData(LabelCharacterPolicy::AsciiOnly))); attr.SetReadOnly(m_table->IsReadOnly()); } else { @@ -536,7 +541,9 @@ wxString ColPlayerWidget::GetCellValue(const wxSheetCoords &p_coords) void ColPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) { - m_table->RenameColHeaderStrategy(p_coords.GetRow(), p_coords.GetCol(), p_value); + m_table->RenameColHeaderStrategy( + p_coords.GetRow(), p_coords.GetCol(), + LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); } wxSheetCellAttr ColPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetAttr_Type) const @@ -548,6 +555,7 @@ wxSheetCellAttr ColPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetA if (m_table->GetColHeaderRowCount() > 0) { attr.SetForegroundColour( m_table->GetPlayerColor(m_table->GetColHeaderPlayer(p_coords.GetRow()))); + attr.SetEditor(wxSheetCellEditor(new LabelEditorRefData(LabelCharacterPolicy::AsciiOnly))); attr.SetReadOnly(m_table->IsReadOnly()); } else { From de62d30a2286c4eb423702c6d6e8672f255f3e15 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 1 Jul 2026 10:34:20 +0100 Subject: [PATCH 04/14] Add label UX for player labels --- src/gui/edittext.cc | 33 +++++++++++++------------ src/gui/edittext.h | 10 +++++--- src/gui/efgpanel.cc | 59 +++++++++++++++++++++++---------------------- src/gui/nfgpanel.cc | 5 ++-- 4 files changed, 56 insertions(+), 51 deletions(-) diff --git a/src/gui/edittext.cc b/src/gui/edittext.cc index 9db1e7a07..be647aab8 100644 --- a/src/gui/edittext.cc +++ b/src/gui/edittext.cc @@ -55,18 +55,19 @@ void StaticTextButton::OnLeftClick(wxMouseEvent &p_event) // class EditableText //========================================================================= -EditableText::EditableText(wxWindow *p_parent, int p_id, const wxString &p_value, - const wxPoint &p_position, const wxSize &p_size) +EditableLabelText::EditableLabelText(wxWindow *p_parent, int p_id, const wxString &p_value, + const wxPoint &p_position, const wxSize &p_size) : wxPanel(p_parent, p_id, p_position, p_size) { m_staticText = new StaticTextButton(this, wxID_ANY, p_value, wxPoint(0, 0), p_size, wxALIGN_LEFT); Connect(m_staticText->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(EditableText::OnClick)); + wxCommandEventHandler(EditableLabelText::OnClick)); - m_textCtrl = new wxTextCtrl(this, wxID_ANY, p_value, wxPoint(0, 0), p_size, wxTE_PROCESS_ENTER); + m_textCtrl = new LabelTextCtrl(this, wxID_ANY, p_value, LabelCharacterPolicy::AsciiOnly, + wxPoint(0, 0), p_size, wxTE_PROCESS_ENTER); Connect(m_textCtrl->GetId(), wxEVT_COMMAND_TEXT_ENTER, - wxCommandEventHandler(EditableText::OnAccept)); + wxCommandEventHandler(EditableLabelText::OnAccept)); auto *topSizer = new wxBoxSizer(wxHORIZONTAL); topSizer->Add(m_staticText, 1, wxALIGN_CENTER, 0); @@ -76,7 +77,7 @@ EditableText::EditableText(wxWindow *p_parent, int p_id, const wxString &p_value wxWindowBase::Layout(); } -void EditableText::BeginEdit() +void EditableLabelText::BeginEdit() { m_textCtrl->SetValue(m_staticText->GetLabel()); m_textCtrl->SetSelection(-1, -1); @@ -86,10 +87,10 @@ void EditableText::BeginEdit() m_textCtrl->SetFocus(); } -void EditableText::EndEdit(bool p_accept) +void EditableLabelText::EndEdit(bool p_accept) { if (p_accept) { - m_staticText->SetLabel(m_textCtrl->GetValue()); + m_staticText->SetLabel(m_textCtrl->GetNormalizedValue()); } GetSizer()->Show(m_textCtrl, false); @@ -97,52 +98,52 @@ void EditableText::EndEdit(bool p_accept) GetSizer()->Layout(); } -wxString EditableText::GetValue() const +wxString EditableLabelText::GetValue() const { if (GetSizer()->IsShown(m_textCtrl)) { - return m_textCtrl->GetValue(); + return m_textCtrl->GetNormalizedValue(); } else { return m_staticText->GetLabel(); } } -void EditableText::SetValue(const wxString &p_value) +void EditableLabelText::SetValue(const wxString &p_value) { m_textCtrl->SetValue(p_value); m_staticText->SetLabel(p_value); } -bool EditableText::SetForegroundColour(const wxColour &p_color) +bool EditableLabelText::SetForegroundColour(const wxColour &p_color) { m_staticText->SetForegroundColour(p_color); m_textCtrl->SetForegroundColour(p_color); return true; } -bool EditableText::SetBackgroundColour(const wxColour &p_color) +bool EditableLabelText::SetBackgroundColour(const wxColour &p_color) { m_staticText->SetBackgroundColour(p_color); m_textCtrl->SetBackgroundColour(p_color); return true; } -bool EditableText::SetFont(const wxFont &p_font) +bool EditableLabelText::SetFont(const wxFont &p_font) { m_staticText->SetFont(p_font); m_textCtrl->SetFont(p_font); return true; } -void EditableText::OnClick(wxCommandEvent &) +void EditableLabelText::OnClick(wxCommandEvent &) { wxCommandEvent event(wxEVT_COMMAND_BUTTON_CLICKED); event.SetId(GetId()); wxPostEvent(GetParent(), event); } -void EditableText::OnAccept(wxCommandEvent &) +void EditableLabelText::OnAccept(wxCommandEvent &) { EndEdit(true); wxCommandEvent event(wxEVT_COMMAND_TEXT_ENTER); diff --git a/src/gui/edittext.h b/src/gui/edittext.h index 688ff1962..1ae4d9075 100644 --- a/src/gui/edittext.h +++ b/src/gui/edittext.h @@ -23,6 +23,8 @@ #ifndef GAMBIT_GUI_EDITTEXT_H #define GAMBIT_GUI_EDITTEXT_H +#include "editlabel.h" + namespace Gambit::GUI { //! //! A StaticTextButton is a wxStaticText object that generates a @@ -43,9 +45,9 @@ class StaticTextButton final : public wxStaticText { //! This control looks like a wxStaticText, but when clicked it shows //! a wxTextCtrl to edit the value. //! -class EditableText : public wxPanel { +class EditableLabelText : public wxPanel { StaticTextButton *m_staticText; - wxTextCtrl *m_textCtrl; + LabelTextCtrl *m_textCtrl; /// @name Event handlers //@{ @@ -56,8 +58,8 @@ class EditableText : public wxPanel { //@} public: - EditableText(wxWindow *p_parent, int p_id, const wxString &p_value, const wxPoint &p_position, - const wxSize &p_size); + EditableLabelText(wxWindow *p_parent, int p_id, const wxString &p_value, + const wxPoint &p_position, const wxSize &p_size); bool IsEditing() const { return GetSizer()->IsShown(m_textCtrl); } void BeginEdit(); diff --git a/src/gui/efgpanel.cc b/src/gui/efgpanel.cc index 555989bb5..8c124ccea 100644 --- a/src/gui/efgpanel.cc +++ b/src/gui/efgpanel.cc @@ -43,7 +43,7 @@ namespace Gambit::GUI { #include "bitmaps/color.xpm" #include "bitmaps/person.xpm" -class gbtTreePlayerIcon : public wxStaticBitmap { +class TreePlayerIcon : public wxStaticBitmap { private: int m_player; @@ -51,21 +51,21 @@ class gbtTreePlayerIcon : public wxStaticBitmap { void OnLeftClick(wxMouseEvent &); public: - gbtTreePlayerIcon(wxWindow *p_parent, int p_player); + TreePlayerIcon(wxWindow *p_parent, int p_player); DECLARE_EVENT_TABLE() }; -BEGIN_EVENT_TABLE(gbtTreePlayerIcon, wxStaticBitmap) -EVT_LEFT_DOWN(gbtTreePlayerIcon::OnLeftClick) +BEGIN_EVENT_TABLE(TreePlayerIcon, wxStaticBitmap) +EVT_LEFT_DOWN(TreePlayerIcon::OnLeftClick) END_EVENT_TABLE() -gbtTreePlayerIcon::gbtTreePlayerIcon(wxWindow *p_parent, int p_player) +TreePlayerIcon::TreePlayerIcon(wxWindow *p_parent, int p_player) : wxStaticBitmap(p_parent, wxID_ANY, wxBitmap(person_xpm)), m_player(p_player) { } -void gbtTreePlayerIcon::OnLeftClick(wxMouseEvent &) +void TreePlayerIcon::OnLeftClick(wxMouseEvent &) { wxString label; label << "P" << m_player; @@ -74,11 +74,11 @@ void gbtTreePlayerIcon::OnLeftClick(wxMouseEvent &) source.DoDragDrop(wxDrag_DefaultMove); } -class gbtTreePlayerPanel : public wxPanel { +class TreePlayerPanel : public wxPanel { private: GameDocument *m_doc; int m_player; - EditableText *m_playerLabel; + EditableLabelText *m_playerLabel; wxStaticText *m_payoff, *m_nodeValue, *m_nodeProb; wxStaticText *m_infosetValue, *m_infosetProb, *m_belief; @@ -95,7 +95,7 @@ class gbtTreePlayerPanel : public wxPanel { //@} public: - gbtTreePlayerPanel(wxWindow *, GameDocument *, int p_player); + TreePlayerPanel(wxWindow *, GameDocument *, int p_player); void OnUpdate(); void PostPendingChanges(); @@ -103,18 +103,18 @@ class gbtTreePlayerPanel : public wxPanel { DECLARE_EVENT_TABLE() }; -BEGIN_EVENT_TABLE(gbtTreePlayerPanel, wxPanel) -EVT_CHAR(gbtTreePlayerPanel::OnChar) +BEGIN_EVENT_TABLE(TreePlayerPanel, wxPanel) +EVT_CHAR(TreePlayerPanel::OnChar) END_EVENT_TABLE() -gbtTreePlayerPanel::gbtTreePlayerPanel(wxWindow *p_parent, GameDocument *p_doc, int p_player) +TreePlayerPanel::TreePlayerPanel(wxWindow *p_parent, GameDocument *p_doc, int p_player) : wxPanel(p_parent, wxID_ANY), m_doc(p_doc), m_player(p_player) { auto *topSizer = new wxBoxSizer(wxVERTICAL); auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); - wxStaticBitmap *playerIcon = new gbtTreePlayerIcon(this, m_player); + wxStaticBitmap *playerIcon = new TreePlayerIcon(this, m_player); labelSizer->Add(playerIcon, 0, wxALL | wxALIGN_CENTER, 0); auto *setColorIcon = new wxBitmapButton(this, wxID_ANY, wxBitmap(color_xpm), wxDefaultPosition, @@ -123,15 +123,16 @@ gbtTreePlayerPanel::gbtTreePlayerPanel(wxWindow *p_parent, GameDocument *p_doc, labelSizer->Add(setColorIcon, 0, wxALL | wxALIGN_CENTER, 0); Connect(setColorIcon->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(gbtTreePlayerPanel::OnSetColor)); + wxCommandEventHandler(TreePlayerPanel::OnSetColor)); - m_playerLabel = new EditableText(this, wxID_ANY, wxT(""), wxDefaultPosition, wxSize(125, -1)); + m_playerLabel = + new EditableLabelText(this, wxID_ANY, wxT(""), wxDefaultPosition, wxSize(125, -1)); m_playerLabel->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); labelSizer->Add(m_playerLabel, 1, wxLEFT | wxEXPAND, 10); Connect(m_playerLabel->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(gbtTreePlayerPanel::OnEditPlayerLabel)); + wxCommandEventHandler(TreePlayerPanel::OnEditPlayerLabel)); Connect(m_playerLabel->GetId(), wxEVT_COMMAND_TEXT_ENTER, - wxCommandEventHandler(gbtTreePlayerPanel::OnAcceptPlayerLabel)); + wxCommandEventHandler(TreePlayerPanel::OnAcceptPlayerLabel)); topSizer->Add(labelSizer, 0, wxALL, 0); @@ -178,7 +179,7 @@ gbtTreePlayerPanel::gbtTreePlayerPanel(wxWindow *p_parent, GameDocument *p_doc, OnUpdate(); } -void gbtTreePlayerPanel::OnUpdate() +void TreePlayerPanel::OnUpdate() { if (!m_doc->GetGame()->IsTree()) { return; @@ -249,7 +250,7 @@ void gbtTreePlayerPanel::OnUpdate() GetSizer()->Fit(this); } -void gbtTreePlayerPanel::OnChar(wxKeyEvent &p_event) +void TreePlayerPanel::OnChar(wxKeyEvent &p_event) { if (p_event.GetKeyCode() == WXK_ESCAPE) { m_playerLabel->EndEdit(false); @@ -259,7 +260,7 @@ void gbtTreePlayerPanel::OnChar(wxKeyEvent &p_event) } } -void gbtTreePlayerPanel::OnSetColor(wxCommandEvent &) +void TreePlayerPanel::OnSetColor(wxCommandEvent &) { wxColourData data; data.SetColour(m_doc->GetStyle().GetPlayerColor(m_doc->GetGame()->GetPlayer(m_player))); @@ -276,13 +277,13 @@ void gbtTreePlayerPanel::OnSetColor(wxCommandEvent &) } } -void gbtTreePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) +void TreePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) { m_doc->PostPendingChanges(); m_playerLabel->BeginEdit(); } -void gbtTreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) +void TreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) { try { m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); @@ -292,7 +293,7 @@ void gbtTreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) } } -void gbtTreePlayerPanel::PostPendingChanges() +void TreePlayerPanel::PostPendingChanges() { if (m_playerLabel->IsEditing()) { m_playerLabel->EndEdit(true); @@ -423,7 +424,7 @@ void gbtTreeChancePanel::OnSetColor(wxCommandEvent &) class gbtTreePlayerToolbar : public wxPanel, public GameView { private: gbtTreeChancePanel *m_chancePanel; - Array m_playerPanels; + Array m_playerPanels; // @name Implementation of GameView members //@{ @@ -444,7 +445,7 @@ gbtTreePlayerToolbar::gbtTreePlayerToolbar(wxWindow *p_parent, GameDocument *p_d topSizer->Add(m_chancePanel, 0, wxALL | wxEXPAND, 5); for (size_t pl = 1; pl <= m_doc->GetGame()->NumPlayers(); pl++) { - m_playerPanels.push_back(new gbtTreePlayerPanel(this, m_doc, pl)); + m_playerPanels.push_back(new TreePlayerPanel(this, m_doc, pl)); topSizer->Add(m_playerPanels[pl], 0, wxALL | wxEXPAND, 5); } @@ -455,27 +456,27 @@ gbtTreePlayerToolbar::gbtTreePlayerToolbar(wxWindow *p_parent, GameDocument *p_d void gbtTreePlayerToolbar::OnUpdate() { while (m_playerPanels.size() < m_doc->GetGame()->NumPlayers()) { - auto *panel = new gbtTreePlayerPanel(this, m_doc, m_playerPanels.size() + 1); + auto *panel = new TreePlayerPanel(this, m_doc, m_playerPanels.size() + 1); m_playerPanels.push_back(panel); GetSizer()->Add(panel, 0, wxALL | wxEXPAND, 5); } while (m_playerPanels.size() > m_doc->GetGame()->NumPlayers()) { - gbtTreePlayerPanel *panel = m_playerPanels.back(); + TreePlayerPanel *panel = m_playerPanels.back(); GetSizer()->Detach(panel); panel->Destroy(); m_playerPanels.pop_back(); } std::for_each(m_playerPanels.begin(), m_playerPanels.end(), - std::mem_fn(&gbtTreePlayerPanel::OnUpdate)); + std::mem_fn(&TreePlayerPanel::OnUpdate)); GetSizer()->Layout(); } void gbtTreePlayerToolbar::PostPendingChanges() { std::for_each(m_playerPanels.begin(), m_playerPanels.end(), - std::mem_fn(&gbtTreePlayerPanel::PostPendingChanges)); + std::mem_fn(&TreePlayerPanel::PostPendingChanges)); } //===================================================================== diff --git a/src/gui/nfgpanel.cc b/src/gui/nfgpanel.cc index f483c78e1..eb918c73e 100644 --- a/src/gui/nfgpanel.cc +++ b/src/gui/nfgpanel.cc @@ -73,7 +73,7 @@ void TablePlayerIcon::OnLeftClick(wxMouseEvent &) class TablePlayerPanel final : public wxPanel { GameDocument *m_doc; int m_player; - EditableText *m_playerLabel; + EditableLabelText *m_playerLabel; wxStaticText *m_payoff; /// @name Event handlers @@ -133,7 +133,8 @@ TablePlayerPanel::TablePlayerPanel(wxWindow *p_parent, NfgPanel *p_nfgPanel, Gam Connect(setColorIcon->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(TablePlayerPanel::OnSetColor)); - m_playerLabel = new EditableText(this, wxID_ANY, wxT(""), wxDefaultPosition, wxSize(125, -1)); + m_playerLabel = + new EditableLabelText(this, wxID_ANY, wxT(""), wxDefaultPosition, wxSize(125, -1)); m_playerLabel->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); labelSizer->Add(m_playerLabel, 1, wxLEFT | wxEXPAND, 5); Connect(m_playerLabel->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, From a6786e0c16a214e29cd948c2f4ebf85c7bcf2252 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 6 Jul 2026 12:05:27 +0100 Subject: [PATCH 05/14] Add guards against empty labels in edit move --- src/gui/dleditmove.cc | 31 +++++++++++++++++++++++++++++++ src/gui/dleditmove.h | 1 + 2 files changed, 32 insertions(+) diff --git a/src/gui/dleditmove.cc b/src/gui/dleditmove.cc index 3dbfc9de5..cdcba1a87 100644 --- a/src/gui/dleditmove.cc +++ b/src/gui/dleditmove.cc @@ -46,6 +46,8 @@ class ActionPanel final : public wxScrolledWindow { wxString GetActionLabel(int p_act) const; Array GetActionProbs() const; + + void FocusActionLabel(int p_act) { m_actionLabels.at(p_act - 1)->SetFocus(); } }; ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) @@ -198,8 +200,37 @@ EditMoveDialog::EditMoveDialog(wxWindow *p_parent, const GameInfoset &p_infoset) Bind(wxEVT_BUTTON, &EditMoveDialog::OnOK, this, wxID_OK); } +bool EditMoveDialog::ValidateLabels() +{ + const wxString infosetLabel = m_infosetLabel->GetNormalizedValue(); + if (infosetLabel.empty()) { + wxRichMessageDialog(this, _("Information set label cannot be empty."), _("Error"), + wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_infosetLabel->SetFocus(); + return false; + } + + for (int act = 1; act <= m_actionPanel->NumActions(); ++act) { + const wxString actionLabel = m_actionPanel->GetActionLabel(act); + if (actionLabel.empty()) { + wxRichMessageDialog(this, _("Action labels cannot be empty."), _("Error"), + wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_actionPanel->FocusActionLabel(act); + return false; + } + } + + return true; +} + void EditMoveDialog::OnOK(wxCommandEvent &p_event) { + if (!ValidateLabels()) { + return; + } + if (!m_infoset->IsChanceInfoset()) { p_event.Skip(); return; diff --git a/src/gui/dleditmove.h b/src/gui/dleditmove.h index 07f90ae76..d9db63065 100644 --- a/src/gui/dleditmove.h +++ b/src/gui/dleditmove.h @@ -34,6 +34,7 @@ class EditMoveDialog final : public wxDialog { LabelTextCtrl *m_infosetLabel; ActionPanel *m_actionPanel; + bool ValidateLabels(); void OnOK(wxCommandEvent &); public: From 111ed57553e0c5cd29e3896dce7367c524d91016 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 6 Jul 2026 12:09:02 +0100 Subject: [PATCH 06/14] Guard against empty strategy labels --- src/gui/labelcell.cc | 6 ++++++ src/gui/nfgtable.cc | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/gui/labelcell.cc b/src/gui/labelcell.cc index 1c1a0abd4..dad184642 100644 --- a/src/gui/labelcell.cc +++ b/src/gui/labelcell.cc @@ -104,6 +104,12 @@ bool LabelEditorRefData::EndEdit(const wxSheetCoords &p_coords, wxSheet *p_sheet auto *textCtrl = wxStaticCast(GetTextCtrl(), LabelTextCtrl); const wxString value = textCtrl->GetNormalizedValue(); + if (value.empty()) { + wxBell(); + textCtrl->SetFocus(); + return false; + } + if (value == p_sheet->GetCellValue(p_coords)) { return false; } diff --git a/src/gui/nfgtable.cc b/src/gui/nfgtable.cc index bf54b5c92..e9296ef29 100644 --- a/src/gui/nfgtable.cc +++ b/src/gui/nfgtable.cc @@ -242,6 +242,13 @@ wxString RowPlayerWidget::GetCellValue(const wxSheetCoords &p_coords) void RowPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) { + const wxString label = LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + return; + } + m_table->RenameRowHeaderStrategy( p_coords.GetCol(), p_coords.GetRow(), LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); @@ -541,6 +548,13 @@ wxString ColPlayerWidget::GetCellValue(const wxSheetCoords &p_coords) void ColPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString &p_value) { + const wxString label = LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + return; + } + m_table->RenameColHeaderStrategy( p_coords.GetRow(), p_coords.GetCol(), LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); From bb6263cfd6958a52380a43b9a4d03cf2f633d5be Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 6 Jul 2026 12:12:41 +0100 Subject: [PATCH 07/14] Guard against empty player labels --- src/gui/efgpanel.cc | 13 ++++++++++++- src/gui/nfgpanel.cc | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/gui/efgpanel.cc b/src/gui/efgpanel.cc index 8c124ccea..bdcb2f4a4 100644 --- a/src/gui/efgpanel.cc +++ b/src/gui/efgpanel.cc @@ -285,8 +285,19 @@ void TreePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) void TreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) { + const wxString label = + LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + m_playerLabel->BeginEdit(); + return; + } + + m_playerLabel->SetValue(label); + try { - m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); + m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), label); } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); diff --git a/src/gui/nfgpanel.cc b/src/gui/nfgpanel.cc index eb918c73e..48557b0e8 100644 --- a/src/gui/nfgpanel.cc +++ b/src/gui/nfgpanel.cc @@ -222,8 +222,19 @@ void TablePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) void TablePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) { + const wxString label = + LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + m_playerLabel->BeginEdit(); + return; + } + + m_playerLabel->SetValue(label); + try { - m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); + m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), label); } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); From aa526b60c9ed06c858d7b2fa932db6474bd8d8fc Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 6 Jul 2026 12:58:37 +0100 Subject: [PATCH 08/14] Reconcile class renaming --- src/gui/edittext.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/edittext.cc b/src/gui/edittext.cc index c17762591..5979dbc11 100644 --- a/src/gui/edittext.cc +++ b/src/gui/edittext.cc @@ -69,8 +69,8 @@ EditableLabelText::EditableLabelText(wxWindow *p_parent, int p_id, const wxStrin Connect(m_textCtrl->GetId(), wxEVT_COMMAND_TEXT_ENTER, wxCommandEventHandler(EditableLabelText::OnAccept)); - m_textCtrl->Bind(wxEVT_KILL_FOCUS, &EditableText::OnTextKillFocus, this); - m_textCtrl->Bind(wxEVT_CHAR_HOOK, &EditableText::OnTextCharHook, this); + m_textCtrl->Bind(wxEVT_KILL_FOCUS, &EditableLabelText::OnTextKillFocus, this); + m_textCtrl->Bind(wxEVT_CHAR_HOOK, &EditableLabelText::OnTextCharHook, this); auto *topSizer = new wxBoxSizer(wxHORIZONTAL); topSizer->Add(m_staticText, 1, wxALIGN_CENTER, 0); From f4629f88ab140efa1a6159265c41bc54e174e7d3 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 6 Jul 2026 13:55:46 +0100 Subject: [PATCH 09/14] Robustness of EditableLabelValue on revert edits --- src/gui/edittext.cc | 7 ++++++- src/gui/edittext.h | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/gui/edittext.cc b/src/gui/edittext.cc index 5979dbc11..ee5c2ba87 100644 --- a/src/gui/edittext.cc +++ b/src/gui/edittext.cc @@ -57,7 +57,7 @@ void StaticTextButton::OnLeftClick(wxMouseEvent &p_event) EditableLabelText::EditableLabelText(wxWindow *p_parent, int p_id, const wxString &p_value, const wxPoint &p_position, const wxSize &p_size) - : wxPanel(p_parent, p_id, p_position, p_size) + : wxPanel(p_parent, p_id, p_position, p_size), m_committedValue(p_value) { m_staticText = new StaticTextButton(this, wxID_ANY, p_value, wxPoint(0, 0), p_size, wxALIGN_LEFT); @@ -95,6 +95,10 @@ void EditableLabelText::EndEdit(bool p_accept) if (p_accept) { m_staticText->SetLabel(m_textCtrl->GetNormalizedValue()); } + else { + m_textCtrl->SetValue(m_committedValue); + m_staticText->SetLabel(m_committedValue); + } GetSizer()->Show(m_textCtrl, false); GetSizer()->Show(m_staticText, true); @@ -141,6 +145,7 @@ wxString EditableLabelText::GetValue() const void EditableLabelText::SetValue(const wxString &p_value) { + m_committedValue = p_value; m_textCtrl->SetValue(p_value); m_staticText->SetLabel(p_value); } diff --git a/src/gui/edittext.h b/src/gui/edittext.h index 88b0817e7..a1b79c3e1 100644 --- a/src/gui/edittext.h +++ b/src/gui/edittext.h @@ -49,6 +49,7 @@ class EditableLabelText : public wxPanel { StaticTextButton *m_staticText; LabelTextCtrl *m_textCtrl; + wxString m_committedValue; bool m_endingEdit = false; /// @name Event handlers From 6ece8030e3622d1eff079fe4a50b82162cac8ab9 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 8 Jul 2026 14:15:14 +0100 Subject: [PATCH 10/14] Correct uniqueness validation in move editing --- src/gui/dleditmove.cc | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/gui/dleditmove.cc b/src/gui/dleditmove.cc index cdcba1a87..3b6d4c2f0 100644 --- a/src/gui/dleditmove.cc +++ b/src/gui/dleditmove.cc @@ -203,14 +203,19 @@ EditMoveDialog::EditMoveDialog(wxWindow *p_parent, const GameInfoset &p_infoset) bool EditMoveDialog::ValidateLabels() { const wxString infosetLabel = m_infosetLabel->GetNormalizedValue(); - if (infosetLabel.empty()) { - wxRichMessageDialog(this, _("Information set label cannot be empty."), _("Error"), - wxOK | wxCENTRE | wxICON_ERROR) - .ShowModal(); - m_infosetLabel->SetFocus(); - return false; + if (!infosetLabel.empty()) { + for (const auto &infoset : m_infoset->GetPlayer()->GetInfosets()) { + if (infoset != m_infoset && infoset->GetLabel() == infosetLabel) { + wxRichMessageDialog(this, _("Information set label must be unique for the player."), + _("Error"), wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_infosetLabel->SetFocus(); + return false; + } + } } + std::set actionLabels; for (int act = 1; act <= m_actionPanel->NumActions(); ++act) { const wxString actionLabel = m_actionPanel->GetActionLabel(act); if (actionLabel.empty()) { @@ -220,8 +225,17 @@ bool EditMoveDialog::ValidateLabels() m_actionPanel->FocusActionLabel(act); return false; } - } + if (contains(actionLabels, actionLabel)) { + wxRichMessageDialog(this, _("Action labels must be unique within the information set."), + _("Error"), wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_actionPanel->FocusActionLabel(act); + return false; + } + + actionLabels.insert(actionLabel); + } return true; } From 3be33ca08ad041701c0c1966e53283ea5ca0a00d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 8 Jul 2026 14:28:52 +0100 Subject: [PATCH 11/14] Correct uniqueness validation in node properties --- src/gui/dleditnode.cc | 24 +++++++++++++++++++++--- src/gui/dleditnode.h | 2 ++ src/gui/dlexcept.h | 7 ++++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/gui/dleditnode.cc b/src/gui/dleditnode.cc index 126e0504b..d146c8e66 100644 --- a/src/gui/dleditnode.cc +++ b/src/gui/dleditnode.cc @@ -24,6 +24,7 @@ #ifndef WX_PRECOMP #include #endif // WX_PRECOMP +#include #include "gambit.h" #include "dleditnode.h" @@ -153,6 +154,25 @@ EditNodeDialog::EditNodeDialog(wxWindow *p_parent, const GameNode &p_node) wxTopLevelWindowBase::Layout(); CenterOnParent(); + + Bind(wxEVT_BUTTON, &EditNodeDialog::OnOK, this, wxID_OK); +} + +void EditNodeDialog::OnOK(wxCommandEvent &p_event) +{ + const wxString nodeLabel = m_nodeLabel->GetNormalizedValue(); + if (!nodeLabel.empty()) { + for (const auto &node : m_node->GetGame()->GetNodes()) { + if (node != m_node && node->GetLabel() == nodeLabel) { + wxRichMessageDialog(this, _("Node label must be unique in the game."), _("Error"), + wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + m_nodeLabel->SetFocus(); + return; + } + } + } + p_event.Skip(); } GameInfoset EditNodeDialog::GetInfoset() const @@ -160,8 +180,6 @@ GameInfoset EditNodeDialog::GetInfoset() const if (m_infoset->GetSelection() == 0) { return nullptr; } - else { - return m_infosetList[m_infoset->GetSelection()]; - } + return m_infosetList[m_infoset->GetSelection()]; } } // namespace Gambit::GUI diff --git a/src/gui/dleditnode.h b/src/gui/dleditnode.h index b4af48204..a1c304893 100644 --- a/src/gui/dleditnode.h +++ b/src/gui/dleditnode.h @@ -32,6 +32,8 @@ class EditNodeDialog final : public wxDialog { wxChoice *m_outcome, *m_infoset; Array m_infosetList; + void OnOK(wxCommandEvent &); + public: // Lifecycle EditNodeDialog(wxWindow *p_parent, const GameNode &p_node); diff --git a/src/gui/dlexcept.h b/src/gui/dlexcept.h index a534d468b..fcf762cbb 100644 --- a/src/gui/dlexcept.h +++ b/src/gui/dlexcept.h @@ -24,15 +24,16 @@ #define DLEXCEPT_H #include +#include namespace Gambit::GUI { // A general-purpose dialog box to display the description of an internal exception. -class ExceptionDialog final : public wxMessageDialog { +class ExceptionDialog final : public wxRichMessageDialog { public: ExceptionDialog(wxWindow *p_parent, const std::string &p_message) - : wxMessageDialog(p_parent, wxString(p_message.c_str(), *wxConvCurrent), - wxT("Internal exception in Gambit"), wxICON_ERROR | wxCANCEL) + : wxRichMessageDialog(p_parent, wxString(p_message.c_str(), *wxConvCurrent), + wxT("Internal exception in Gambit"), wxOK | wxCENTRE | wxICON_ERROR) { } }; From 20858b2e37520d2e3091c2b0049d4cedfff2fa24 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 9 Jul 2026 17:09:32 +0100 Subject: [PATCH 12/14] Improve UX on trying to set duplicate player label --- src/gui/efgpanel.cc | 3 +-- src/gui/nfgpanel.cc | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/gui/efgpanel.cc b/src/gui/efgpanel.cc index 2e4733fd0..73983fcd4 100644 --- a/src/gui/efgpanel.cc +++ b/src/gui/efgpanel.cc @@ -294,14 +294,13 @@ void TreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) return; } - m_playerLabel->SetValue(label); - try { m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), label); } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); } + m_playerLabel->SetValue(m_doc->GetGame()->GetPlayer(m_player)->GetLabel()); } void TreePlayerPanel::PostPendingChanges() diff --git a/src/gui/nfgpanel.cc b/src/gui/nfgpanel.cc index 7dd1e03dd..4e3846437 100644 --- a/src/gui/nfgpanel.cc +++ b/src/gui/nfgpanel.cc @@ -231,14 +231,13 @@ void TablePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) return; } - m_playerLabel->SetValue(label); - try { m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), label); } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); } + m_playerLabel->SetValue(m_doc->GetGame()->GetPlayer(m_player)->GetLabel()); } void TablePlayerPanel::PostPendingChanges() From d6fb54295f33a7a275c1069fb6eccb916ecb743f Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 9 Jul 2026 17:33:37 +0100 Subject: [PATCH 13/14] Better UX on attempting to duplicate strategy labels --- src/gui/nfgtable.cc | 22 +++++++++++++++------- src/gui/nfgtable.h | 4 ++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/gui/nfgtable.cc b/src/gui/nfgtable.cc index e9296ef29..1bd8b5aa0 100644 --- a/src/gui/nfgtable.cc +++ b/src/gui/nfgtable.cc @@ -249,9 +249,12 @@ void RowPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString return; } - m_table->RenameRowHeaderStrategy( + const wxString result = m_table->RenameRowHeaderStrategy( p_coords.GetCol(), p_coords.GetRow(), LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); + if (!result.empty()) { + CallAfter([this, result] { ExceptionDialog(this, result.ToStdString()).ShowModal(); }); + } } wxSheetCellAttr RowPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetAttr_Type) const @@ -555,9 +558,12 @@ void ColPlayerWidget::SetCellValue(const wxSheetCoords &p_coords, const wxString return; } - m_table->RenameColHeaderStrategy( - p_coords.GetRow(), p_coords.GetCol(), + const wxString result = m_table->RenameColHeaderStrategy( + p_coords.GetCol(), p_coords.GetRow(), LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly)); + if (!result.empty()) { + CallAfter([this, result] { ExceptionDialog(this, result.ToStdString()).ShowModal(); }); + } } wxSheetCellAttr ColPlayerWidget::GetAttr(const wxSheetCoords &p_coords, wxSheetAttr_Type) const @@ -1349,7 +1355,7 @@ void TableWidget::RenderGame(wxDC &p_dc, int p_marginX, int p_marginY) p_dc, wxSheetBlock(0, 0, m_payoffSheet->GetNumberRows(), m_payoffSheet->GetNumberCols())); } -void TableWidget::RenameRowHeaderStrategy(int headerCol, int headerRow, const wxString &value) +wxString TableWidget::RenameRowHeaderStrategy(int headerCol, int headerRow, const wxString &value) { const int player = GetRowHeaderPlayer(headerCol); const int strat = GetRowHeaderStrategy(headerCol, headerRow); @@ -1358,11 +1364,12 @@ void TableWidget::RenameRowHeaderStrategy(int headerCol, int headerRow, const wx m_doc->DoSetStrategyLabel(GetStrategyByPlayerAndIndex(player, strat), value); } catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); + return wxString::FromUTF8(ex.what()); } + return ""; } -void TableWidget::RenameColHeaderStrategy(int headerRow, int headerCol, const wxString &value) +wxString TableWidget::RenameColHeaderStrategy(int headerRow, int headerCol, const wxString &value) { const int player = GetColHeaderPlayer(headerRow); const int strat = GetColHeaderStrategy(headerRow, headerCol); @@ -1371,8 +1378,9 @@ void TableWidget::RenameColHeaderStrategy(int headerRow, int headerCol, const wx m_doc->DoSetStrategyLabel(GetStrategyByPlayerAndIndex(player, strat), value); } catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); + return wxString::FromUTF8(ex.what()); } + return ""; } void TableWidget::DeleteRowHeaderStrategy(int headerCol, int headerRow) diff --git a/src/gui/nfgtable.h b/src/gui/nfgtable.h index 0f4e522c2..bf29b6c90 100644 --- a/src/gui/nfgtable.h +++ b/src/gui/nfgtable.h @@ -426,8 +426,8 @@ class TableWidget final : public wxPanel { void GetSVG(const wxString &p_filename, int marginX, int marginY); /// Prints the game as currently displayed, centered on the DC void RenderGame(wxDC &p_dc, int marginX, int marginY); - void RenameRowHeaderStrategy(int headerCol, int headerRow, const wxString &value); - void RenameColHeaderStrategy(int headerRow, int headerCol, const wxString &value); + wxString RenameRowHeaderStrategy(int headerCol, int headerRow, const wxString &value); + wxString RenameColHeaderStrategy(int headerRow, int headerCol, const wxString &value); bool CanDeleteRowHeaderStrategy(int headerCol, int headerRow) const; bool CanDeleteColHeaderStrategy(int headerRow, int headerCol) const; From c2ef2b42fed5c768ba970c1e0fda80da9ed363b7 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 10 Jul 2026 11:36:13 +0100 Subject: [PATCH 14/14] Improve UX of outcome editor popup, especially with respect to duplicate/invalid label explanation --- src/gui/efgdisplay.cc | 137 ++++++++++++++++++++++++++++++++++++------ src/gui/gamedoc.cc | 3 + 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index d38f8625d..a7ebc5bf1 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -50,10 +50,20 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { void OnDismiss() override; private: + struct ValidationResult { + bool ok{true}; + wxString message; + wxTextCtrl *ctrl{nullptr}; + }; + void BuildControls(); void LoadValues(); void PositionPopup(); void OnKeyDown(wxKeyEvent &p_event); + + ValidationResult ValidatePayoffs(std::vector &p_payoffs); + void ShowValidationFailure(const wxString &p_message, wxTextCtrl *p_ctrl); + void ClearValidationFailure(); void RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl); EfgDisplay *m_owner; @@ -63,17 +73,20 @@ class OutcomeEditorPopup : public wxPopupTransientWindow { wxPanel *m_contentPanel; wxTextCtrl *m_labelCtrl; + wxStaticText *m_errorText; wxFlexGridSizer *m_gridSizer; std::vector m_payoffCtrls; int m_initialPlayer{0}; bool m_cancelled{false}; bool m_dismissing{false}; + bool m_committing{false}; + bool m_restoringAfterFailedCommit{false}; }; OutcomeEditorPopup::OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc) : wxPopupTransientWindow(p_owner, wxBORDER_NONE), m_owner(p_owner), m_doc(p_doc), - m_contentPanel(nullptr), m_labelCtrl(nullptr) + m_contentPanel(nullptr), m_labelCtrl(nullptr), m_errorText(nullptr) { SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); @@ -143,6 +156,13 @@ void OutcomeEditorPopup::BuildControls() outerSizer->Add(payoffSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, FromDIP(12)); + m_errorText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_errorText->SetForegroundColour(*wxRED); + m_errorText->Wrap(FromDIP(260)); + m_errorText->Hide(); + + outerSizer->Add(m_errorText, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(12)); + m_contentPanel->SetSizer(outerSizer); popupSizer->Add(m_contentPanel, 1, wxEXPAND | wxALL, FromDIP(1)); @@ -197,8 +217,11 @@ void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) m_initialPlayer = p_initialPlayer; m_cancelled = false; m_dismissing = false; + m_committing = false; + m_restoringAfterFailedCommit = false; LoadValues(); + ClearValidationFailure(); Fit(); PositionPopup(); @@ -217,7 +240,7 @@ void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) void OutcomeEditorPopup::OnDismiss() { - if (m_dismissing) { + if (m_dismissing || m_restoringAfterFailedCommit) { return; } @@ -253,70 +276,146 @@ void OutcomeEditorPopup::Cancel() return; } + ClearValidationFailure(); + m_cancelled = true; Dismiss(); } -bool OutcomeEditorPopup::Commit() +OutcomeEditorPopup::ValidationResult +OutcomeEditorPopup::ValidatePayoffs(std::vector &p_payoffs) { - if (!m_node) { - return false; - } - - std::vector payoffs; - payoffs.reserve(m_payoffCtrls.size()); + p_payoffs.clear(); + p_payoffs.reserve(m_payoffCtrls.size()); - for (auto *ctrl : m_payoffCtrls) { + for (size_t player = 1; player <= m_payoffCtrls.size(); ++player) { + wxTextCtrl *ctrl = m_payoffCtrls[player - 1]; wxString value = ctrl->GetValue(); if (value.EndsWith(wxT("/"))) { value.RemoveLast(); + ctrl->SetValue(value); + ctrl->SetInsertionPointEnd(); } try { lexical_cast(value.ToStdString()); } catch (const std::exception &) { - RestoreAfterFailedCommit(ctrl); - return false; + return {false, + wxString::Format(_("Payoff for player %lu is not a valid number."), + static_cast(player)), + ctrl}; } - payoffs.push_back(value); + p_payoffs.push_back(value); + } + + return {}; +} + +bool OutcomeEditorPopup::Commit() +{ + if (!m_node || m_committing) { + return false; + } + + m_committing = true; + + std::vector payoffs; + const ValidationResult validation = ValidatePayoffs(payoffs); + + if (!validation.ok) { + ShowValidationFailure(validation.message, validation.ctrl); + RestoreAfterFailedCommit(validation.ctrl); + m_committing = false; + return false; } try { m_doc->DoSetOutcomeData(m_node, m_labelCtrl->GetValue(), payoffs); } catch (const std::exception &ex) { - ExceptionDialog(m_owner, ex.what()).ShowModal(); + ShowValidationFailure(wxString::FromUTF8(ex.what()), m_labelCtrl); + RestoreAfterFailedCommit(m_labelCtrl); + m_committing = false; return false; } + ClearValidationFailure(); + m_dismissing = true; Dismiss(); m_dismissing = false; m_node = nullptr; + m_committing = false; return true; } -void OutcomeEditorPopup::RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl) +void OutcomeEditorPopup::ShowValidationFailure(const wxString &p_message, wxTextCtrl *p_ctrl) { wxBell(); + if (m_errorText) { + m_errorText->SetLabel(p_message); + m_errorText->Wrap(FromDIP(260)); + m_errorText->Show(); + } + + if (m_contentPanel) { + m_contentPanel->Layout(); + } + + Fit(); + PositionPopup(); + + if (p_ctrl) { + p_ctrl->SetFocus(); + p_ctrl->SelectAll(); + } +} + +void OutcomeEditorPopup::ClearValidationFailure() +{ + if (!m_errorText) { + return; + } + + m_errorText->SetLabel(wxEmptyString); + m_errorText->Hide(); + + if (m_contentPanel) { + m_contentPanel->Layout(); + } +} + +void OutcomeEditorPopup::RestoreAfterFailedCommit(wxTextCtrl *p_invalidCtrl) +{ + if (m_restoringAfterFailedCommit) { + return; + } + + m_restoringAfterFailedCommit = true; + CallAfter([this, p_invalidCtrl]() { + m_restoringAfterFailedCommit = false; + if (!m_node) { return; } PositionPopup(); - Popup(); - p_invalidCtrl->SetFocus(); - p_invalidCtrl->SelectAll(); + if (!IsShown()) { + Popup(); + } + + wxTextCtrl *ctrl = p_invalidCtrl ? p_invalidCtrl : m_labelCtrl; + ctrl->SetFocus(); + ctrl->SelectAll(); }); } - //-------------------------------------------------------------------------- // Bitmap drawing functions //-------------------------------------------------------------------------- diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index 6dc4a2088..401072d27 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -735,6 +735,9 @@ void GameDocument::DoSetOutcomeData(const GameNode &p_node, const wxString &p_la outcome = m_game->NewOutcome(p_label.ToStdString()); m_game->SetOutcome(p_node, outcome); } + else { + outcome->SetLabel(label); + } for (size_t player = 1; player <= GetGame()->NumPlayers(); ++player) { outcome->SetPayoff(GetGame()->GetPlayer(player), Number(p_payoffs[player - 1].ToStdString()));