diff --git a/Makefile.am b/Makefile.am index 5ef18af9b..573c3780b 100644 --- a/Makefile.am +++ b/Makefile.am @@ -471,8 +471,12 @@ 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/labelcell.cc \ + src/gui/labelcell.h \ src/gui/dlabout.cc \ src/gui/dlabout.h \ src/gui/dleditmove.cc \ diff --git a/src/gui/dleditmove.cc b/src/gui/dleditmove.cc index 9bfdf0ea6..3b6d4c2f0 100644 --- a/src/gui/dleditmove.cc +++ b/src/gui/dleditmove.cc @@ -30,21 +30,24 @@ #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; + + void FocusActionLabel(int p_act) { m_actionLabels.at(p_act - 1)->SetFocus(); } }; ActionPanel::ActionPanel(wxWindow *p_parent, const GameInfoset &p_infoset) @@ -57,7 +60,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 +85,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 +114,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 +142,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); { @@ -195,8 +200,51 @@ 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()) { + 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()) { + wxRichMessageDialog(this, _("Action labels cannot be empty."), _("Error"), + wxOK | wxCENTRE | wxICON_ERROR) + .ShowModal(); + 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; +} + void EditMoveDialog::OnOK(wxCommandEvent &p_event) { + if (!ValidateLabels()) { + return; + } + if (!m_infoset->IsChanceInfoset()) { p_event.Skip(); return; @@ -215,9 +263,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..d9db63065 100644 --- a/src/gui/dleditmove.h +++ b/src/gui/dleditmove.h @@ -23,15 +23,18 @@ #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; + bool ValidateLabels(); void OnOK(wxCommandEvent &); public: @@ -39,11 +42,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/dleditnode.cc b/src/gui/dleditnode.cc index 7613a4a6f..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" @@ -39,9 +40,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); @@ -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 9608c2c4f..a1c304893 100644 --- a/src/gui/dleditnode.h +++ b/src/gui/dleditnode.h @@ -23,19 +23,23 @@ #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; + void OnOK(wxCommandEvent &); + public: // Lifecycle 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/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) { } }; diff --git a/src/gui/editlabel.cc b/src/gui/editlabel.cc new file mode 100644 index 000000000..0bed480d5 --- /dev/null +++ b/src/gui/editlabel.cc @@ -0,0 +1,146 @@ +// +// 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, LabelCharacterPolicy p_policy) +{ + switch (p_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, + LabelCharacterPolicy p_policy) +{ + 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, p_policy)) { + 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..a0034163a --- /dev/null +++ b/src/gui/editlabel.h @@ -0,0 +1,63 @@ +// +// 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); + static bool IsAllowedNonWhitespace(wxUniChar p_char, LabelCharacterPolicy p_policy); + + 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, + long p_style = 0); + + wxString GetNormalizedValue(); +}; + +} // namespace Gambit::GUI + +#endif // EDITLABEL_H diff --git a/src/gui/edittext.cc b/src/gui/edittext.cc index 8c54e3900..ee5c2ba87 100644 --- a/src/gui/edittext.cc +++ b/src/gui/edittext.cc @@ -55,21 +55,22 @@ 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) - : wxPanel(p_parent, p_id, p_position, 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_committedValue(p_value) { 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)); - 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); @@ -79,7 +80,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); @@ -89,10 +90,14 @@ 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()); + } + else { + m_textCtrl->SetValue(m_committedValue); + m_staticText->SetLabel(m_committedValue); } GetSizer()->Show(m_textCtrl, false); @@ -100,7 +105,7 @@ void EditableText::EndEdit(bool p_accept) GetSizer()->Layout(); } -void EditableText::AcceptEdit() +void EditableLabelText::AcceptEdit() { if (!IsEditing() || m_endingEdit) { return; @@ -116,7 +121,7 @@ void EditableText::AcceptEdit() m_endingEdit = false; } -void EditableText::CancelEdit() +void EditableLabelText::CancelEdit() { if (!IsEditing() || m_endingEdit) { return; @@ -127,60 +132,61 @@ void EditableText::CancelEdit() m_endingEdit = false; } -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_committedValue = 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 &) { AcceptEdit(); } +void EditableLabelText::OnAccept(wxCommandEvent &) { AcceptEdit(); } -void EditableText::OnTextKillFocus(wxFocusEvent &p_event) +void EditableLabelText::OnTextKillFocus(wxFocusEvent &p_event) { AcceptEdit(); p_event.Skip(); } -void EditableText::OnTextCharHook(wxKeyEvent &p_event) +void EditableLabelText::OnTextCharHook(wxKeyEvent &p_event) { if (p_event.GetKeyCode() == WXK_ESCAPE && IsEditing()) { CancelEdit(); diff --git a/src/gui/edittext.h b/src/gui/edittext.h index 2f7a62625..a1b79c3e1 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,10 +45,11 @@ 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; + wxString m_committedValue; bool m_endingEdit = false; /// @name Event handlers @@ -65,8 +68,8 @@ class EditableText : public wxPanel { void CancelEdit(); 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/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/efgpanel.cc b/src/gui/efgpanel.cc index 767a49bb8..73983fcd4 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,23 +277,33 @@ 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 &) { + const wxString label = + LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + m_playerLabel->BeginEdit(); + return; + } + 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(); } + m_playerLabel->SetValue(m_doc->GetGame()->GetPlayer(m_player)->GetLabel()); } -void gbtTreePlayerPanel::PostPendingChanges() +void TreePlayerPanel::PostPendingChanges() { if (!m_playerLabel->IsEditing()) { return; @@ -426,7 +437,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 //@{ @@ -447,7 +458,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); } @@ -458,27 +469,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/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())); diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index af3e103ad..afbf2f1d7 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -995,7 +995,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())); @@ -1030,14 +1030,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()); diff --git a/src/gui/labelcell.cc b/src/gui/labelcell.cc new file mode 100644 index 000000000..dad184642 --- /dev/null +++ b/src/gui/labelcell.cc @@ -0,0 +1,121 @@ +// +// 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.empty()) { + wxBell(); + textCtrl->SetFocus(); + return false; + } + + 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/nfgpanel.cc b/src/gui/nfgpanel.cc index 2fc3902d2..4e3846437 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, @@ -221,12 +222,22 @@ 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; + } + 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(); } + m_playerLabel->SetValue(m_doc->GetGame()->GetPlayer(m_player)->GetLabel()); } void TablePlayerPanel::PostPendingChanges() diff --git a/src/gui/nfgtable.cc b/src/gui/nfgtable.cc index 13dc0bc45..1bd8b5aa0 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,19 @@ 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); + const wxString label = LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + return; + } + + 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 @@ -252,6 +266,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 +551,19 @@ 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); + const wxString label = LabelTextCtrl::Normalize(p_value, true, LabelCharacterPolicy::AsciiOnly); + + if (label.empty()) { + wxBell(); + return; + } + + 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 @@ -548,6 +575,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 { @@ -1327,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); @@ -1336,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); @@ -1349,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;