diff --git a/Makefile.am b/Makefile.am index 57892d6c78..5e93fa0a27 100644 --- a/Makefile.am +++ b/Makefile.am @@ -509,10 +509,13 @@ gambit_SOURCES = \ src/gui/dlnfglogit.h \ src/gui/efgdisplay.cc \ src/gui/efgdisplay.h \ + src/gui/efgdragdrop.cc \ src/gui/efglayout.cc \ src/gui/efglayout.h \ + src/gui/efgnodemenu.cc \ src/gui/efgpanel.cc \ src/gui/efgpanel.h \ + src/gui/efgtooltip.cc \ src/gui/efgprofile.cc \ src/gui/efgprofile.h \ src/gui/gamedoc.cc \ @@ -520,8 +523,10 @@ gambit_SOURCES = \ src/gui/gameframe.cc \ src/gui/gameframe.h \ src/gui/menuconst.h \ + src/gui/nfgheadergrid.cc \ src/gui/nfgpanel.cc \ src/gui/nfgpanel.h \ + src/gui/nfgpayoffgrid.cc \ src/gui/nfgprofile.cc \ src/gui/nfgprofile.h \ src/gui/nfgtable.cc \ diff --git a/doc/gui.efg.rst b/doc/gui.efg.rst index a9e52a88bd..4a97a3e8d7 100644 --- a/doc/gui.efg.rst +++ b/doc/gui.efg.rst @@ -27,21 +27,23 @@ strategic players in the game. Adding moves ------------ -There are two options for adding moves to a tree: drag-and-drop -and the :guilabel:`Insert move` dialog. - -#. Moves can be added to the - tree using a drag-and-drop idiom. From the player list window, drag - the player icon located to the left of the player who will have the - move to any terminal node in the game tree. The tree will be extended - with a new move for that player, with two actions at the new move. - Adding a move for the chance player is done the same way, except the - dice icon appearing to the left of the chance player in the player - list window is used instead of the player icon. For the chance player, - the two actions created will each be given a probability weight of - one-half. If the desired move has more than two actions, additional - actions can be added by dragging the same player's icon to the move - node; this will add one action to the move each time this is done. +There are two options for adding moves to a tree, both reached by +right-clicking the node where the move should go: a quick per-player +submenu, and the more flexible :guilabel:`Insert move` dialog. + +#. Right-clicking any terminal node in the tree and choosing + :guilabel:`Insert move for` opens a submenu listing every player + (each shown with its assigned colour), plus the chance player. Choosing + a player extends the tree with a new move for that player at the node, + with two actions. Choosing the chance player adds a chance move instead, + with its two actions each given a probability weight of one-half. + + The player who moves at an *existing* move can be changed the same + way: right-clicking a nonterminal node offers the same submenu, + labeled :guilabel:`Assign this move to`, listing the other players + to which the move can be reassigned. A move cannot currently be + changed between the chance player and a personal player this way -- + nor can it be, at present, using any other method in the interface. .. image:: screens/insertmove.* :width: 33% @@ -49,8 +51,8 @@ and the :guilabel:`Insert move` dialog. :align: right :target: _images/insertmove.png -2. Click on any terminal node in - the tree, and select :menuselection:`Edit --> Insert move` +2. Right-click any terminal node in + the tree, and choose :guilabel:`Insert move` to display the :guilabel:`insert move` dialog. The dialog is intended to read like a sentence: @@ -66,17 +68,16 @@ and the :guilabel:`Insert move` dialog. Otherwise, it is set automatically to the number of actions at the selected information set. -The two methods can be useful in different contexts. -The drag-and-drop approach -is a bit quicker to use, especially when creating trees that have few -actions at each move. The dialog approach is a bit more flexible, in -that a move can be added for a new, as-yet-undefined player, a move -can be added directly into an existing information set, and a move can -be immediately given more than two actions. +Both are reached with a right-click, and can be useful in different +contexts. The per-player submenu is a bit quicker to use, especially +when creating trees that have few actions at each move. The dialog +approach is a bit more flexible, in that a move can be added for a new, +as-yet-undefined player, a move can be added directly into an existing +information set, and a move can be immediately given more than two +actions. A single action can also be added to, or removed from, an existing -move without drag-and-drop, using the :guilabel:`Edit move` dialog -described next. +move using the :guilabel:`Edit move` dialog described next. .. _editing-moves: @@ -86,8 +87,8 @@ Editing moves The properties of an existing move -- the label of its information set, the player to which it belongs, and the actions available at it --- can be changed by clicking on any node belonging to the move and -selecting :menuselection:`Edit --> Move`. This displays the +-- can be changed by right-clicking on any node belonging to the move +and choosing :guilabel:`Edit move`. This displays the :guilabel:`Edit move` dialog, which lists a text field for the information set's label, a dropdown for the player to which the move belongs, and a row for each action, showing its label and, for moves @@ -165,25 +166,30 @@ the tree. It is often efficient to create the structure once, and then copy it as needed elsewhere. Gambit provides a convenient idiom for this. Clicking on any -nonterminal node and dragging to another node results in a context-aware -popup menu. Depending on the destination node, this menu offers -the option of copying the subtree rooted at the original node, moving -it entirely, or placing the destination node in the same information set -as the source node. +nonterminal node and dragging it to another node results in a +context-aware popup menu. While dragging, the node under the pointer is +outlined in green if dropping there is possible, or in red if not. +Depending on the destination node, this menu offers the option of +copying the subtree rooted at the original node, moving it entirely, or +placing the destination node in the same information set as the source +node. Moving a subtree onto one of its own descendants is never +offered (though shown, disabled), since the destination would cease to +exist partway through the move; copying onto a descendant remains +available, since the copy is made before anything is removed. Removing parts of a game tree ----------------------------- Two deletion operations are supported on extensive games. To delete -the entire subtree rooted at a node, click on that node and select -:menuselection:`Edit --> Delete subtree`. +the entire subtree rooted at a node, right-click that node and choose +:guilabel:`Delete subtree`. -To delete an individual move from the game, click on one of the direct -children of that node, and select -:menuselection:`Edit --> Delete parent`. This operation +To delete an individual move from the game, right-click one of the +direct children of that node, and choose +:guilabel:`Delete parent`. This operation deletes the parent node, as well as all the children of the parent -other than the selected node. The selected child node now takes the +other than the clicked node. The clicked child node now takes the place of the parent node in the tree. @@ -196,13 +202,12 @@ Gambit provides several methods to help manage the information structure in an extensive game. When building a tree, new moves can be placed in a given information -set using the :ref:`Insert move dialog `. -Additionally, new moves can be -created using the drag-and-drop idiom by holding down the :kbd:`Shift` -key and dragging a node in the tree. During the drag operation, the -cursor changes to the move icon . Dropping the move icon on another -node places the target node in the same information set as the node -where the drag operation began. +set using the :ref:`Insert move dialog `. Additionally, a +node can be placed in the same information set as another using the +drag-and-drop idiom described in :ref:`copying-trees`: dragging one +node onto another and choosing :guilabel:`Put node in same information +set` (or, when dropping on a terminal node, :guilabel:`Insert move +using same information set`) from the popup menu. .. image:: screens/editnode.* :width: 33% @@ -211,7 +216,8 @@ where the drag operation began. :target: _images/editnode.png The information set to which a node belongs can also be set by -selecting :menuselection:`Edit --> Node`. This displays the +right-clicking the node and choosing :guilabel:`Node properties` +(or by double-clicking the node). This displays the :guilabel:`node properties` dialog. The :guilabel:`Information set` dropdown defaults to the current information set to which the node belongs, and contains @@ -231,12 +237,12 @@ corresponding nodes in the original subtree. In many cases, though, these trees differ in the information available to some or all of the players. To help speed the process of adjusting information sets in bulk, Gambit offers a "reveal" operation, which breaks information -sets based on the action taken at a particular node. Click on a node -at which the action taken is to be made known subsequently to other -players, and select :menuselection:`Edit --> Reveal`. This displays a +sets based on the action taken at a particular node. Right-click the +node at which the action taken is to be made known subsequently to +other players, and choose :guilabel:`Reveal`. This displays a dialog listing the players in the game. Check the boxes next to the players who observe the outcome of the move at the node, and click -:guilabel:`OK`. The information sets at nodes below the selected one +:guilabel:`OK`. The information sets at nodes below the clicked one are adjusted based on the action selected at this node. .. note:: @@ -263,14 +269,15 @@ zero for all players, and pops up an editing panel for that outcome. For nodes with existing outcomes, clicking on any of the displayed payoffs pops up an editing panel for that outcome. -Outcomes may also be moved or copied using drag-and-drop. -Left-clicking and dragging an outcome to another node pops up a -context-aware menu which allows the outcome to be moved or copied, or -to create a new outcome with the same payoffs as the original one at -the new node. +Outcomes may also be moved, or shared between nodes, using drag-and-drop. +Left-clicking and dragging an outcome to another node (highlighted, as with +moving and copying subtrees, in green or red as a valid or invalid drop +target) pops up a context-aware menu which allows the outcome to be moved to +the new node, or for the new node to use the same outcome as the original -- +so that editing the payoffs at either node updates both. -To remove an outcome from a node, click on the node, and -select :menuselection:`Edit --> Remove outcome`. +To remove an outcome from a node, right-click the node, and +choose :guilabel:`Remove outcome`. @@ -471,5 +478,7 @@ system different from the system on which it was saved, Gambit tries to match the font style as closely as possible when the original font is not available. -The color-coding for each player can be changed by clicking on the -color icon to the left of the corresponding player. +The color-coding for each player (and for chance) can be changed on the +:guilabel:`Players` page of the :guilabel:`Game properties` dialog +(:menuselection:`Edit --> Game`), by clicking the color swatch next to the +player's name. diff --git a/doc/gui.general.rst b/doc/gui.general.rst index e96baa0556..1a516cc9e5 100644 --- a/doc/gui.general.rst +++ b/doc/gui.general.rst @@ -10,17 +10,25 @@ General layout of the main window :align: right :target: _images/overview.png -The frame presenting a game consists of two principal panels. The main -panel, to the right, displays the game graphically; in this case, -showing the game tree of a simple one-card poker game. To the left is -the player panel, which lists the players in the game; here, Fred and -Alice are the players. Note that where applicable, information is -color-coded to match the colors assigned to the players: Fred's moves -and payoffs are all presented in red, and Alice's in blue. The color -assigned to a player can be changed by clicking on the color icon -located to the left of the player's name on the player panel. Player -names are edited by clicking on the player's name, and editing the -name in the text control that appears. +The frame presenting a game consists of a single main panel, which displays +the game graphically; in this case, showing the game tree of a simple +one-card poker game. Note that where applicable, information is color-coded +to match the colors assigned to the players: Fred's moves and payoffs are +presented in red, and Alice's in blue. Player names and colors are set on +the :guilabel:`Players` page of the :guilabel:`Game properties` dialog +(:menuselection:`Edit --> Game`): each player is listed with a text field +for its name and a color swatch beside it, which opens a color picker when +clicked. A new player is added to the game using +:menuselection:`Edit --> Add player`; the Players page itself does not yet +support adding, removing, or reordering players. + +Hovering the mouse pointer over a node in the tree briefly displays a small +window showing, for each player, that player's expected payoff from that +point in the game onward, under the currently-selected profile. For a node +that is not a terminal node, this window also shows the probability of +reaching that node and its information set, the information set's value to +the player who moves there, and that player's belief about being at this +particular node within the information set. Two additional panels are available. Selecting :menuselection:`Tools --> Dominance` toggles diff --git a/doc/gui.nash.rst b/doc/gui.nash.rst index b9b9a200f6..2fee7672af 100644 --- a/doc/gui.nash.rst +++ b/doc/gui.nash.rst @@ -164,17 +164,20 @@ is proportional to the probability with which the action is played. :align: right :target: _images/beliefs.png -Clicking on any node in the tree displays additional information about -the profile at that node. -The player panel displays -information relevant to the selected node, including the payoff to all players -conditional on reaching the node, as well as information about Alice's -beliefs at the node. +Hovering the mouse pointer over any node in the tree briefly displays +additional information about the profile at that node, in a small window +next to the node, including each player's expected payoff from that point +in the game onward, as well as information about Alice's beliefs at the +node. The computed profiles can also be viewed in the reduced strategic game. Clicking on the strategic game icon changes the view to the reduced strategic form of the game, and shows the equilibrium profiles -converted to mixed strategies in the strategic game. +converted to mixed strategies in the strategic game. Hovering the mouse +pointer over a strategy's row or column label there shows the analogous +information for that strategy: its probability under the current +profile, its payoff if played against the others' current play, and its +player's overall payoff under the current profile. diff --git a/doc/gui.nfg.rst b/doc/gui.nfg.rst index c19855ca8d..b9f8880e85 100644 --- a/doc/gui.nfg.rst +++ b/doc/gui.nfg.rst @@ -20,8 +20,9 @@ game after any changes to the extensive game. Strategies in a reduced strategic game are assigned numeric labels for identification. These labels are assigned via a deterministic algorithm for constructing the reduced strategic game from an extensive game. -Click a strategy label to open a popup listing the action selected at each -information set where the reduced strategy specifies an action. +Hovering the mouse pointer over a strategy's row or column label briefly +displays a small window listing the action selected at each information set +where the reduced strategy specifies an action. Strategic games may also be input directly. To create a new strategic game, select :menuselection:`File --> New --> Strategic game`, @@ -55,6 +56,15 @@ The payoffs in each cell are colour-coded by player. In the standard two-player arrangement, Gambit displays the row player's payoff first and the column player's payoff second. +Whenever a profile has been computed and selected, hovering the mouse +pointer over any strategy's row or column label also shows that +strategy's probability under the current profile, the payoff its player +would get by playing that strategy alone against the others' current +play, and that player's overall payoff under the current profile. +Comparing this payoff-if-played figure across a player's strategies is +how to check whether the current profile is actually a best response for +that player. + Row and column hierarchies -------------------------- @@ -98,17 +108,19 @@ each strategy of the third player. Rearranging the table --------------------- -To change the table arrangement, drag a player from the player list to -the row-label or column-label area. - -When the drop menu appears, choose where to place the player in the row -or column hierarchy. Gambit updates the display without changing the -game itself. - -The available positions depend on the current arrangement. Placing a -player before another player makes the moved player a higher level in -the hierarchy; placing the player after another player makes the moved -player a lower level. +To change the table arrangement, right-click any row or column label and +choose a player from the menu that appears. If that side of the table +already has one or more players assigned to it, the menu offers +:guilabel:`Place player before` and :guilabel:`Place player after` +submenus, each listing every player; if not, it offers a single +:guilabel:`Use as row player` (or :guilabel:`Use as column player`) +submenu instead. Gambit updates the display without changing the game +itself. + +The available positions depend on the current arrangement. Choosing a +player from :guilabel:`Place player before` makes that player a higher +level in the hierarchy than the row or column that was right-clicked; +choosing one from :guilabel:`Place player after` makes it a lower level. .. image:: screens/pd3.* :width: 50% diff --git a/src/gui/dlgameprop.cc b/src/gui/dlgameprop.cc index 0a4412f181..7d11bf1107 100644 --- a/src/gui/dlgameprop.cc +++ b/src/gui/dlgameprop.cc @@ -20,74 +20,306 @@ // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. // +#include +#include + #include #ifndef WX_PRECOMP #include #endif // WX_PRECOMP +#include +#include +#ifdef __WXMAC__ +#include +#else +#include +#endif #include "gamedoc.h" #include "dlgameprop.h" +#include "editlabel.h" namespace Gambit::GUI { + +namespace { +const wxColour kInvalidLabelBg(255, 220, 220); + +// wxGenericColourDialog, not the platform's native wxColourDialog, on macOS/Cocoa only: +// its native colour panel has no real Cancel -- any interaction with it commits live, +// with no way back short of canceling this entire properties dialog. The generic dialog +// draws its own honest OK/Cancel buttons. Elsewhere, the native dialog's Cancel already +// works properly, and the generic dialog's header isn't guaranteed to be available (it +// isn't shipped with the GTK/MSW dev packages used by CI, only bundled on macOS/Homebrew). +#ifdef __WXMAC__ +using PlatformColourDialog = wxGenericColourDialog; +#else +using PlatformColourDialog = wxColourDialog; +#endif +} // namespace + +//======================================================================== +// class PlayerLabelPanel +//======================================================================== + +// A fixed list of the game's players (plus chance), each with a color swatch +// button that opens a color picker; every player but chance also gets an +// editable label (chance's label is reserved and immutable). Adding, deleting, +// and reordering players is not supported here yet. +class PlayerLabelPanel final : public wxPanel { +public: + struct Row { + GamePlayer player; + wxColour color; // staged: not written to the document until the dialog's OK is committed + LabelTextCtrl *labelCtrl = nullptr; // null for the chance row -- its label isn't editable + wxBitmapButton *colorButton = nullptr; + }; + +private: + GameDocument *m_doc; + std::vector m_rows; + wxColour m_defaultBg; + std::function m_onChanged; + + void OnSetColor(int p_index); + // Builds a fresh color-swatch button for a row and swaps it in for the old one, rather + // than mutating the existing button's bitmap in place -- see OnSetColor() for why. + void RecreateColorButton(int p_index); + wxBitmapButton *MakeColorButton(int p_index); + +public: + PlayerLabelPanel(wxWindow *p_parent, GameDocument *p_doc, + const std::function &p_onChanged); + + int NumRows() const { return static_cast(m_rows.size()); } + GamePlayer GetPlayer(int p_index) const { return m_rows.at(p_index).player; } + wxString GetPlayerLabel(int p_index) const + { + const Row &row = m_rows.at(p_index); + return row.labelCtrl ? row.labelCtrl->GetNormalizedValue() + : wxString::FromUTF8(row.player->GetLabel()); + } + wxColour GetPlayerColor(int p_index) const { return m_rows.at(p_index).color; } + + // Highlights any empty or duplicate player labels, and returns a description + // of the first problem found, or an empty string if all are valid. Chance's + // (uneditable) row is never a source of invalidity. + wxString ValidateLabels(); +}; + +PlayerLabelPanel::PlayerLabelPanel(wxWindow *p_parent, GameDocument *p_doc, + const std::function &p_onChanged) + : wxPanel(p_parent, wxID_ANY), m_doc(p_doc), m_onChanged(p_onChanged) +{ + auto *gridSizer = new wxFlexGridSizer(3, FromDIP(5), FromDIP(10)); + gridSizer->AddGrowableCol(2, 1); + + for (const auto &player : m_doc->GetGame()->GetPlayersWithChance()) { + Row row; + row.player = player; + row.color = m_doc->GetStyle().GetPlayerColor(player); + m_rows.push_back(row); + + const int index = static_cast(m_rows.size()) - 1; + Row &stored = m_rows[index]; + + stored.colorButton = MakeColorButton(index); + // The border keeps the swatch's spacing from the panel edge sizer-owned rather than + // relying on the button's own native chrome, which can shift after the button is + // recreated (see RecreateColorButton()) if left to the platform to decide. + gridSizer->Add(stored.colorButton, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(5)); + + wxString number; + if (!player->IsChance()) { + number << player->GetNumber(); + } + gridSizer->Add(new wxStaticText(this, wxID_STATIC, number), 0, + wxALIGN_CENTER_VERTICAL | wxALIGN_RIGHT); + + if (player->IsChance()) { + // Chance's label is reserved and can't be changed (GamePlayerRep::SetLabel throws on + // IsChance()) -- just display it. + gridSizer->Add(new wxStaticText(this, wxID_STATIC, wxString::FromUTF8(player->GetLabel())), + 1, wxALIGN_CENTER_VERTICAL); + } + else { + stored.labelCtrl = new LabelTextCtrl(this, wxID_ANY, wxString::FromUTF8(player->GetLabel())); + m_defaultBg = stored.labelCtrl->GetBackgroundColour(); + stored.labelCtrl->Bind(wxEVT_TEXT, [this](wxCommandEvent &p_event) { + m_onChanged(); + p_event.Skip(); + }); + stored.labelCtrl->Bind(wxEVT_KILL_FOCUS, [this](wxFocusEvent &p_event) { + m_onChanged(); + p_event.Skip(); + }); + gridSizer->Add(stored.labelCtrl, 1, wxEXPAND); + } + } + + SetSizer(gridSizer); +} + +wxBitmapButton *PlayerLabelPanel::MakeColorButton(int p_index) +{ + Row &row = m_rows.at(p_index); + auto *button = new wxBitmapButton(this, wxID_ANY, MakeColorSwatch(row.color), wxDefaultPosition, + wxDefaultSize, wxNO_BORDER); + button->SetToolTip(_("Change the color for this player")); + button->Bind(wxEVT_BUTTON, [this, p_index](wxCommandEvent &) { OnSetColor(p_index); }); + return button; +} + +void PlayerLabelPanel::OnSetColor(int p_index) +{ + Row &row = m_rows.at(p_index); + + wxColourData data; + data.SetColour(row.color); + PlatformColourDialog dialog(this, &data); + wxString title; + if (row.player->IsChance()) { + title = _("Choose color for chance"); + } + else { + title << _("Choose color for player ") << row.player->GetNumber(); + } + dialog.SetTitle(title); + + if (dialog.ShowModal() != wxID_OK) { + return; + } + + // Staged only -- not written to the document until the dialog's OK is committed + // (by GameFrame::OnEditGame), so canceling the dialog leaves colors untouched. + row.color = dialog.GetColourData().GetColour(); + + // Recreated (rather than mutated in place via SetBitmap()) because SetBitmap() on an + // existing wxBitmapButton visibly shifted its rendered content on this platform. + // Deferred via CallAfter(): this destroys the very button whose click invoked this + // handler, which is still on the call stack -- see ActionPanel::AddAction() in + // dleditmove.cc for the same hazard. + CallAfter([this, p_index]() { RecreateColorButton(p_index); }); +} + +void PlayerLabelPanel::RecreateColorButton(int p_index) +{ + Row &row = m_rows.at(p_index); + + wxBitmapButton *oldButton = row.colorButton; + wxBitmapButton *newButton = MakeColorButton(p_index); + + GetSizer()->Replace(oldButton, newButton); + oldButton->Destroy(); + row.colorButton = newButton; + + Layout(); +} + +wxString PlayerLabelPanel::ValidateLabels() +{ + const int numRows = NumRows(); + wxString message; + + for (int i = 0; i < numRows; i++) { + if (!m_rows[i].labelCtrl) { + continue; // chance's row has no editable label to validate + } + const wxString value = m_rows[i].labelCtrl->GetValue(); + bool invalid = value.empty(); + for (int other = 0; !invalid && other < numRows; other++) { + if (other != i && m_rows[other].labelCtrl && m_rows[other].labelCtrl->GetValue() == value) { + invalid = true; + } + } + + m_rows[i].labelCtrl->SetBackgroundColour(invalid ? kInvalidLabelBg : m_defaultBg); + m_rows[i].labelCtrl->Refresh(); + + if (invalid && message.empty()) { + message = + value.empty() ? _("Player labels cannot be empty.") : _("Player labels must be unique."); + } + } + return message; +} + //======================================================================== // class GamePropertiesDialog //======================================================================== GamePropertiesDialog::GamePropertiesDialog(wxWindow *p_parent, GameDocument *p_doc) - : wxDialog(p_parent, wxID_ANY, _("Game properties"), wxDefaultPosition), m_doc(p_doc) + : wxDialog(p_parent, wxID_ANY, _("Game properties"), wxDefaultPosition, wxDefaultSize, + wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER), + m_doc(p_doc) { auto *topSizer = new wxBoxSizer(wxVERTICAL); - auto *titleSizer = new wxBoxSizer(wxHORIZONTAL); - titleSizer->Add(new wxStaticText(this, wxID_STATIC, _("Title")), 0, wxALL | wxALIGN_CENTER, 5); - m_title = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(m_doc->GetGame()->GetTitle()), - wxDefaultPosition, wxSize(400, -1)); + auto *notebook = new wxNotebook(this, wxID_ANY); + + auto *generalPanel = new wxPanel(notebook); + auto *generalSizer = new wxBoxSizer(wxVERTICAL); + auto *titleSizer = new wxBoxSizer(wxHORIZONTAL); + titleSizer->Add(new wxStaticText(generalPanel, wxID_STATIC, _("Title")), 0, + wxALL | wxALIGN_CENTER, 5); + m_title = + new wxTextCtrl(generalPanel, wxID_ANY, wxString::FromUTF8(m_doc->GetGame()->GetTitle()), + wxDefaultPosition, wxSize(400, -1)); titleSizer->Add(m_title, 1, wxALL | wxALIGN_CENTER, 5); - topSizer->Add(titleSizer, 0, wxALL | wxEXPAND, 0); + generalSizer->Add(titleSizer, 0, wxALL | wxEXPAND, 0); auto *commentSizer = new wxBoxSizer(wxHORIZONTAL); - commentSizer->Add(new wxStaticText(this, wxID_STATIC, _("Comment")), 0, wxALL | wxALIGN_CENTER, - 5); - m_comment = - new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(m_doc->GetGame()->GetDescription()), - wxDefaultPosition, wxSize(400, -1), wxTE_MULTILINE); + commentSizer->Add(new wxStaticText(generalPanel, wxID_STATIC, _("Comment")), 0, + wxALL | wxALIGN_CENTER, 5); + m_comment = new wxTextCtrl(generalPanel, wxID_ANY, + wxString::FromUTF8(m_doc->GetGame()->GetDescription()), + wxDefaultPosition, wxSize(400, -1), wxTE_MULTILINE); commentSizer->Add(m_comment, 1, wxALL | wxALIGN_CENTER, 5); - topSizer->Add(commentSizer, 1, wxALL | wxEXPAND, 0); + generalSizer->Add(commentSizer, 1, wxALL | wxEXPAND, 0); - auto *boxSizer = new wxStaticBoxSizer(wxVERTICAL, this, _("Information about this game")); + auto *boxSizer = + new wxStaticBoxSizer(wxVERTICAL, generalPanel, _("Information about this game")); - boxSizer->Add( - new wxStaticText(this, wxID_STATIC, wxString(_("Filename: ")) + m_doc->GetFilename()), 0, - wxALL, 5); + boxSizer->Add(new wxStaticText(generalPanel, wxID_STATIC, + wxString(_("Filename: ")) + m_doc->GetFilename()), + 0, wxALL, 5); const Game game = m_doc->GetGame(); - { - wxString label; - label << _("Number of players: ") << game->NumPlayers(); - boxSizer->Add(new wxStaticText(this, wxID_STATIC, label), 0, wxALL, 5); - } if (game->IsConstSum()) { - boxSizer->Add(new wxStaticText(this, wxID_STATIC, _("This is a constant-sum game")), 0, wxALL, - 5); + boxSizer->Add(new wxStaticText(generalPanel, wxID_STATIC, _("This is a constant-sum game")), 0, + wxALL, 5); } else { - boxSizer->Add(new wxStaticText(this, wxID_STATIC, _("This is not a constant-sum game")), 0, - wxALL, 5); + boxSizer->Add( + new wxStaticText(generalPanel, wxID_STATIC, _("This is not a constant-sum game")), 0, + wxALL, 5); } if (game->IsTree()) { if (game->IsPerfectRecall()) { - boxSizer->Add(new wxStaticText(this, wxID_STATIC, _("This is a game of perfect recall")), 0, - wxALL, 5); + boxSizer->Add( + new wxStaticText(generalPanel, wxID_STATIC, _("This is a game of perfect recall")), 0, + wxALL, 5); } else { - boxSizer->Add(new wxStaticText(this, wxID_STATIC, _("This is not a game of perfect recall")), - 0, wxALL, 5); + boxSizer->Add( + new wxStaticText(generalPanel, wxID_STATIC, _("This is not a game of perfect recall")), + 0, wxALL, 5); } } - topSizer->Add(boxSizer, 0, wxALL | wxEXPAND, 5); + generalSizer->Add(boxSizer, 0, wxALL | wxEXPAND, 5); + generalPanel->SetSizer(generalSizer); + notebook->AddPage(generalPanel, _("General")); + + m_playerPanel = new PlayerLabelPanel(notebook, m_doc, [this]() { UpdateValidation(); }); + notebook->AddPage(m_playerPanel, _("Players")); + + topSizer->Add(notebook, 1, wxALL | wxEXPAND, 5); + + m_errorText = new wxStaticText(this, wxID_STATIC, wxEmptyString); + m_errorText->SetForegroundColour(*wxRED); + topSizer->Add(m_errorText, 0, wxLEFT | wxRIGHT | wxBOTTOM | wxEXPAND, 5); auto *buttonSizer = new wxBoxSizer(wxHORIZONTAL); buttonSizer->Add(new wxButton(this, wxID_CANCEL, _("Cancel")), 0, wxALL, 5); @@ -103,6 +335,34 @@ GamePropertiesDialog::GamePropertiesDialog(wxWindow *p_parent, GameDocument *p_d wxTopLevelWindowBase::Layout(); CenterOnParent(); + + UpdateValidation(); +} + +void GamePropertiesDialog::UpdateValidation() +{ + const wxString message = m_playerPanel->ValidateLabels(); + m_errorText->SetLabel(message); + if (auto *ok = FindWindow(wxID_OK)) { + ok->Enable(message.empty()); + } +} + +int GamePropertiesDialog::NumRows() const { return m_playerPanel->NumRows(); } + +GamePlayer GamePropertiesDialog::GetPlayer(int p_index) const +{ + return m_playerPanel->GetPlayer(p_index); +} + +wxString GamePropertiesDialog::GetPlayerLabel(int p_index) const +{ + return m_playerPanel->GetPlayerLabel(p_index); +} + +wxColour GamePropertiesDialog::GetPlayerColor(int p_index) const +{ + return m_playerPanel->GetPlayerColor(p_index); } } // namespace Gambit::GUI diff --git a/src/gui/dlgameprop.h b/src/gui/dlgameprop.h index d22dc7ff91..d89533a4b6 100644 --- a/src/gui/dlgameprop.h +++ b/src/gui/dlgameprop.h @@ -24,9 +24,16 @@ #define GAMBIT_GUI_DLGAMEPROP_H namespace Gambit::GUI { + +class PlayerLabelPanel; + class GamePropertiesDialog final : public wxDialog { GameDocument *m_doc; wxTextCtrl *m_title, *m_comment; + PlayerLabelPanel *m_playerPanel; + wxStaticText *m_errorText; + + void UpdateValidation(); public: // Lifecycle @@ -35,6 +42,15 @@ class GamePropertiesDialog final : public wxDialog { // Data access (only valid when ShowModal() returns with wxID_OK) wxString GetTitle() const override { return m_title->GetValue(); } wxString GetDescription() const { return m_comment->GetValue(); } + + // Player (and chance) labels and colors, as edited on the Players page. Row 0 + // is chance; the rest are the game's personal players. Both labels and + // colors are staged only within the dialog and are not applied to the + // document until ShowModal() returns wxID_OK and the caller commits them. + int NumRows() const; + GamePlayer GetPlayer(int p_index) const; + wxString GetPlayerLabel(int p_index) const; + wxColour GetPlayerColor(int p_index) const; }; } // namespace Gambit::GUI diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index 7408a04970..c4648ac3a9 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -21,552 +21,41 @@ // #include // for std::min -#include + #include #ifndef WX_PRECOMP #include #endif // WX_PRECOMP -#include -#include #include "games.h" #include "efgdisplay.h" #include "menuconst.h" -#include "dlexcept.h" -#include "valnumber.h" namespace Gambit::GUI { -class OutcomeEditorPopup : public wxPopupTransientWindow { -public: - OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc); - - void BeginEdit(const GameNode &p_node, int p_initialPlayer = 0); - bool Commit(); - void Cancel(); - -protected: - 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; - GameDocument *m_doc; - - GameNode m_node; - - 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_errorText(nullptr) -{ - SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); - - BuildControls(); - - Bind(wxEVT_CHAR_HOOK, &OutcomeEditorPopup::OnKeyDown, this); -} - -void OutcomeEditorPopup::BuildControls() -{ - auto *popupSizer = new wxBoxSizer(wxVERTICAL); - - m_contentPanel = new wxPanel(this); - m_contentPanel->SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); - - auto *outerSizer = new wxBoxSizer(wxVERTICAL); - - auto *heading = new wxStaticText(m_contentPanel, wxID_ANY, _("Outcome")); - - wxFont headingFont = heading->GetFont(); - headingFont.SetWeight(wxFONTWEIGHT_BOLD); - headingFont.SetPointSize(headingFont.GetPointSize() + 1); - heading->SetFont(headingFont); - - outerSizer->Add(heading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); - - auto *labelSizer = new wxFlexGridSizer(2, FromDIP(7), FromDIP(12)); - labelSizer->AddGrowableCol(1, 1); - - labelSizer->Add(new wxStaticText(m_contentPanel, wxID_ANY, _("Label")), 0, - wxALIGN_CENTER_VERTICAL); - - m_labelCtrl = new wxTextCtrl(m_contentPanel, wxID_ANY); - m_labelCtrl->SetMinSize(wxSize(FromDIP(180), -1)); - labelSizer->Add(m_labelCtrl, 1, wxEXPAND); - - outerSizer->Add(labelSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); - - auto *payoffHeading = new wxStaticText(m_contentPanel, wxID_ANY, _("Payoffs")); - - wxFont payoffHeadingFont = payoffHeading->GetFont(); - payoffHeadingFont.SetWeight(wxFONTWEIGHT_BOLD); - payoffHeading->SetFont(payoffHeadingFont); - - outerSizer->Add(payoffHeading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); - - auto *payoffSizer = new wxFlexGridSizer(2, FromDIP(7), FromDIP(12)); - payoffSizer->AddGrowableCol(1, 1); - - const Game game = m_doc->GetGame(); - - for (size_t player = 1; player <= m_doc->GetGame()->NumPlayers(); ++player) { - const GamePlayer gamePlayer = game->GetPlayer(player); - - payoffSizer->Add( - new wxStaticText(m_contentPanel, wxID_ANY, wxString::FromUTF8(gamePlayer->GetLabel())), 0, - wxALIGN_CENTER_VERTICAL); - - auto *payoffCtrl = new wxTextCtrl(m_contentPanel, wxID_ANY); - - payoffCtrl->SetValidator(NumberValidator(nullptr)); - payoffCtrl->SetMinSize(wxSize(FromDIP(100), -1)); - - payoffSizer->Add(payoffCtrl, 1, wxEXPAND); - m_payoffCtrls.push_back(payoffCtrl); - } - - outerSizer->Add(payoffSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | 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)); - - SetSizerAndFit(popupSizer); -} - -void OutcomeEditorPopup::LoadValues() -{ - const GameOutcome outcome = m_node ? m_node->GetOutcome() : nullptr; - - if (!outcome) { - m_labelCtrl->Clear(); - - for (auto *ctrl : m_payoffCtrls) { - ctrl->SetValue(wxT("0")); - } - - return; - } - - m_labelCtrl->SetValue(wxString::FromUTF8(outcome->GetLabel())); - - const Game game = m_doc->GetGame(); - - for (size_t player = 1; player <= m_payoffCtrls.size(); ++player) { - const std::string payoff = outcome->GetPayoff(game->GetPlayer(player)); - - m_payoffCtrls[player - 1]->SetValue(wxString(payoff.c_str(), *wxConvCurrent)); - } -} - -void OutcomeEditorPopup::PositionPopup() -{ - auto entry = m_owner->GetLayout().GetNodeEntry(m_node); - if (!entry) { - return; - } - - int clientX, clientY; - m_owner->CalcScrolledPosition(m_owner->LayoutToDevice(entry->GetX() + 20), - m_owner->LayoutToDevice(entry->GetY()), &clientX, &clientY); - - const wxPoint screenPoint = m_owner->ClientToScreen(wxPoint(clientX, clientY)); - - Position(screenPoint, wxSize(FromDIP(8), FromDIP(8))); -} - -void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) -{ - m_node = p_node; - m_initialPlayer = p_initialPlayer; - m_cancelled = false; - m_dismissing = false; - m_committing = false; - m_restoringAfterFailedCommit = false; - - LoadValues(); - ClearValidationFailure(); - Fit(); - PositionPopup(); - - Popup(); - - if (m_initialPlayer > 0 && m_initialPlayer <= static_cast(m_payoffCtrls.size())) { - wxTextCtrl *ctrl = m_payoffCtrls[m_initialPlayer - 1]; - ctrl->SetFocus(); - ctrl->SelectAll(); - } - else { - m_labelCtrl->SetFocus(); - m_labelCtrl->SetInsertionPointEnd(); - } -} - -void OutcomeEditorPopup::OnDismiss() -{ - if (m_dismissing || m_restoringAfterFailedCommit) { - return; - } - - if (m_cancelled) { - m_cancelled = false; - m_node = nullptr; - return; - } - - Commit(); -} - -void OutcomeEditorPopup::OnKeyDown(wxKeyEvent &p_event) -{ - switch (p_event.GetKeyCode()) { - case WXK_ESCAPE: - Cancel(); - return; - - case WXK_RETURN: - case WXK_NUMPAD_ENTER: - Commit(); - return; - - default: - p_event.Skip(); - } -} - -void OutcomeEditorPopup::Cancel() -{ - if (!m_node) { - return; - } - - ClearValidationFailure(); - - m_cancelled = true; - Dismiss(); -} - -OutcomeEditorPopup::ValidationResult -OutcomeEditorPopup::ValidatePayoffs(std::vector &p_payoffs) -{ - p_payoffs.clear(); - p_payoffs.reserve(m_payoffCtrls.size()); - - 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 &) { - return {false, - wxString::Format(_("Payoff for player %lu is not a valid number."), - static_cast(player)), - ctrl}; - } - - 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) { - 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::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(); - - if (!IsShown()) { - Popup(); - } - - wxTextCtrl *ctrl = p_invalidCtrl ? p_invalidCtrl : m_labelCtrl; - ctrl->SetFocus(); - ctrl->SelectAll(); - }); -} -//-------------------------------------------------------------------------- -// Bitmap drawing functions -//-------------------------------------------------------------------------- - -static wxBitmap MakeOutcomeBitmap() -{ - wxBitmap bitmap(24, 24); - wxMemoryDC dc; - dc.SelectObject(bitmap); - dc.Clear(); - dc.SetPen(wxPen(*wxBLACK, 1, wxPENSTYLE_SOLID)); - // Make a gold-colored background - dc.SetBrush(wxBrush(wxColour(255, 215, 0), wxBRUSHSTYLE_SOLID)); - dc.DrawCircle(12, 12, 10); - dc.SetFont(wxFont(12, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); - dc.SetTextForeground(wxColour(0, 192, 0)); - - int width, height; - dc.GetTextExtent(wxT("u"), &width, &height); - dc.DrawText(wxT("u"), 12 - width / 2, 12 - height / 2); - return bitmap; -} - -//-------------------------------------------------------------------------- -// class PlayerDropTarget -//-------------------------------------------------------------------------- - -class PlayerDropTarget : public wxTextDropTarget { - EfgDisplay *m_owner; - GameDocument *m_model; - - bool OnDropPlayer(const GameNode &p_node, const wxString &p_text, const wxPoint &p_pos); - bool OnDropOutcome(const GameNode &p_node, const wxString &p_text, const wxPoint &p_pos); - bool OnDropTreeNode(const GameNode &p_node, const wxString &p_text, const wxPoint &p_pos); - -public: - explicit PlayerDropTarget(EfgDisplay *p_owner) - : m_owner(p_owner), m_model(p_owner->GetDocument()) - { - } - - bool OnDropText(wxCoord x, wxCoord y, const wxString &p_text) override; -}; - -// -// This recurses the subtree starting at 'p_node' looking for a node -// with the ID 'p_id'. -// -static GameNode GetNode(const GameNode &p_node, int p_id) -{ - if (p_node->GetNumber() == p_id) { - return p_node; - } - if (p_node->IsTerminal()) { - return nullptr; - } - for (const auto &child : p_node->GetChildren()) { - if (const auto node = GetNode(child, p_id)) { - return node; - } - } - return nullptr; -} - -bool PlayerDropTarget::OnDropPlayer(const GameNode &p_node, const wxString &p_text, - const wxPoint &p_pos) -{ - long pl; - if (!p_text.Right(p_text.Length() - 1).ToLong(&pl)) { - return false; - } - - const Game efg = m_model->GetGame(); - const GamePlayer player = ((pl == 0) ? efg->GetChance() : efg->GetPlayer(pl)); - - return m_owner->ShowPlayerDropMenu(p_node, player, p_pos); -} - -bool PlayerDropTarget::OnDropOutcome(const GameNode &p_node, const wxString &p_text, - const wxPoint &p_pos) -{ - long n; - p_text.Right(p_text.Length() - 1).ToLong(&n); - - const GameNode srcNode = GetNode(m_model->GetGame()->GetRoot(), n); - if (!srcNode || srcNode == p_node || !srcNode->GetOutcome()) { - return false; - } - - return m_owner->ShowOutcomeDropMenu(p_node, srcNode, p_pos); -} - -bool PlayerDropTarget::OnDropTreeNode(const GameNode &p_node, const wxString &p_text, - const wxPoint &p_pos) -{ - long n; - p_text.Right(p_text.Length() - 1).ToLong(&n); - - const GameNode srcNode = GetNode(m_model->GetGame()->GetRoot(), n); - if (!srcNode || srcNode == p_node || srcNode->IsTerminal()) { - return false; - } - - return m_owner->ShowTreeDropMenu(p_node, srcNode, p_pos); -} - -bool PlayerDropTarget::OnDropText(wxCoord p_x, wxCoord p_y, const wxString &p_text) -{ - const Game efg = m_owner->GetDocument()->GetGame(); - - int x, y; - m_owner->CalcUnscrolledPosition(p_x, p_y, &x, &y); - - x = m_owner->DeviceToLayout(x); - y = m_owner->DeviceToLayout(y); - - const GameNode node = m_owner->GetLayout().NodeHitTest(x, y); - if (!node) { - return false; - } - - try { - switch (static_cast(p_text[0])) { - case 'N': - return OnDropTreeNode(node, p_text, wxPoint(p_x, p_y)); - case 'P': - return OnDropPlayer(node, p_text, wxPoint(p_x, p_y)); - case 'O': - return OnDropOutcome(node, p_text, wxPoint(p_x, p_y)); - default: - return false; - } - } - catch (std::exception &ex) { - ExceptionDialog(m_owner, ex.what()).ShowModal(); - return false; - } -} - //---------------------------------------------------------------------- // EfgDisplay: Member functions //---------------------------------------------------------------------- BEGIN_EVENT_TABLE(EfgDisplay, wxScrolledWindow) EVT_MOTION(EfgDisplay::OnMouseMotion) +EVT_LEAVE_WINDOW(EfgDisplay::OnLeaveWindow) EVT_LEFT_DOWN(EfgDisplay::OnLeftClick) EVT_LEFT_DCLICK(EfgDisplay::OnLeftDoubleClick) EVT_MAGNIFY(EfgDisplay::OnMagnify) EVT_RIGHT_DOWN(EfgDisplay::OnRightClick) -EVT_KEY_DOWN(EfgDisplay::OnKeyEvent) EVT_SIZE(EfgDisplay::OnSize) +EVT_MENU_RANGE(GBT_MENU_EDIT_SET_PLAYER_BASE, + GBT_MENU_EDIT_SET_PLAYER_BASE + gbtSetPlayerMenuCount - 1, + EfgDisplay::OnSetPlayerMenu) +EVT_MENU(GBT_MENU_EDIT_INSERT_MOVE, EfgDisplay::OnEditInsertMove) +EVT_MENU(GBT_MENU_EDIT_DELETE_TREE, EfgDisplay::OnEditDeleteTree) +EVT_MENU(GBT_MENU_EDIT_DELETE_PARENT, EfgDisplay::OnEditDeleteParent) +EVT_MENU(GBT_MENU_EDIT_REMOVE_OUTCOME, EfgDisplay::OnEditRemoveOutcome) +EVT_MENU(GBT_MENU_EDIT_REVEAL, EfgDisplay::OnEditReveal) +EVT_MENU(GBT_MENU_EDIT_NODE, EfgDisplay::OnEditNode) +EVT_MENU(GBT_MENU_EDIT_MOVE, EfgDisplay::OnEditMove) END_EVENT_TABLE() //---------------------------------------------------------------------- @@ -574,225 +63,23 @@ END_EVENT_TABLE() //---------------------------------------------------------------------- EfgDisplay::EfgDisplay(wxWindow *p_parent, GameDocument *p_doc) - : wxScrolledWindow(p_parent), GameView(p_doc), m_layout(p_doc), m_zoom(100), - m_outcomeEditor(new OutcomeEditorPopup(this, p_doc)) + : wxScrolledWindow(p_parent), GameView(p_doc), m_layout(p_doc), m_zoom(100) { wxWindow::SetBackgroundColour(wxColour(250, 250, 250)); - wxWindow::SetDropTarget(new PlayerDropTarget(this)); + InitDropTarget(); + InitPopups(); + + m_hoverTimer.SetOwner(this); + Bind(wxEVT_TIMER, &EfgDisplay::OnHoverTimer, this); MakeMenus(); OnUpdate(); } -void EfgDisplay::MakeMenus() -{ - m_nodeMenu = new wxMenu; - m_nodeMenu->Append(GBT_MENU_EDIT_INSERT_MOVE, _("&Insert move"), _("Insert a move")); - m_nodeMenu->Append(GBT_MENU_EDIT_REVEAL, _("&Reveal"), _("Reveal choice at node")); - m_nodeMenu->AppendSeparator(); - - m_nodeMenu->Append(GBT_MENU_EDIT_DELETE_TREE, _("&Delete subtree"), - _("Delete the subtree starting at the selected node")); - m_nodeMenu->Append(GBT_MENU_EDIT_DELETE_PARENT, _("Delete &parent"), - _("Delete the node directly before the selected node")); - m_nodeMenu->Append(GBT_MENU_EDIT_REMOVE_OUTCOME, _("Remove &outcome"), - _("Remove the outcome from the selected node")); - m_nodeMenu->AppendSeparator(); - - m_nodeMenu->Append(GBT_MENU_EDIT_NODE, _("&Node properties"), _("Edit properties of the node")); - m_nodeMenu->Append(GBT_MENU_EDIT_MOVE, _("&Edit move"), _("Edit properties of the move")); - - m_nodeMenu->AppendSeparator(); - m_nodeMenu->Append(GBT_MENU_EDIT_GAME, _("&Game properties"), _("Edit properties of the game")); -} - -bool EfgDisplay::ShowPlayerDropMenu(const GameNode &p_targetNode, const GamePlayer &p_player, - const wxPoint &p_pos) -{ - if (!p_targetNode || !p_player) { - return false; - } - if (!p_targetNode->IsTerminal() && p_targetNode->GetPlayer() == p_player) { - return false; // already this player's move; nothing to do via drag-and-drop - } - - const int operationId = wxWindow::NewControlId(); - - wxMenu menu; - - if (p_targetNode->IsTerminal()) { - menu.Append(operationId, _("Insert move for this player")); - } - else { - menu.Append(operationId, _("Assign this move to this player")); - } - - const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); - if (selection != operationId) { - return false; - } - - try { - if (p_targetNode->IsTerminal()) { - m_doc->DoInsertMove(p_targetNode, p_player, 2); - } - else { - m_doc->DoSetPlayer(p_targetNode, p_player); - } - - return true; - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } - - return false; -} - -bool EfgDisplay::ShowTreeDropMenu(const GameNode &p_targetNode, const GameNode &p_sourceNode, - const wxPoint &p_pos) -{ - if (!p_targetNode || !p_sourceNode || p_sourceNode->IsTerminal()) { - return false; - } - - const bool canCopyOrMoveTree = p_targetNode->IsTerminal(); - const bool canUseSameInfoset = - (!p_targetNode->IsTerminal() && - p_targetNode->GetChildren().size() == p_sourceNode->GetChildren().size()) || - p_targetNode->IsTerminal(); - - if (!canCopyOrMoveTree && !canUseSameInfoset) { - return false; - } - - const int copyTreeId = wxWindow::NewControlId(); - const int moveTreeId = wxWindow::NewControlId(); - const int infosetId = wxWindow::NewControlId(); - - wxMenu menu; - - if (canCopyOrMoveTree) { - menu.Append(copyTreeId, _("Copy subtree here")); - menu.Append(moveTreeId, _("Move subtree here")); - } - - if (canUseSameInfoset) { - if (!menu.GetMenuItems().empty()) { - menu.AppendSeparator(); - } - - if (p_targetNode->IsTerminal()) { - menu.Append(infosetId, _("Insert move using same information set")); - } - else { - menu.Append(infosetId, _("Put node in same information set")); - } - } - - const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); - - try { - if (selection == copyTreeId) { - m_doc->DoCopyTree(p_targetNode, p_sourceNode); - return true; - } - if (selection == moveTreeId) { - m_doc->DoMoveTree(p_targetNode, p_sourceNode); - return true; - } - if (selection == infosetId) { - if (!p_targetNode->IsTerminal()) { - m_doc->DoSetInfoset(p_targetNode, p_sourceNode->GetInfoset()); - } - else { - m_doc->DoAppendMove(p_targetNode, p_sourceNode->GetInfoset()); - } - return true; - } - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } - - return false; -} - -bool EfgDisplay::ShowOutcomeDropMenu(const GameNode &p_targetNode, const GameNode &p_sourceNode, - const wxPoint &p_pos) -{ - if (!p_targetNode || !p_sourceNode || p_targetNode == p_sourceNode || - !p_sourceNode->GetOutcome()) { - return false; - } - - const int useSameOutcomeId = wxWindow::NewControlId(); - const int copyOutcomeId = wxWindow::NewControlId(); - const int moveOutcomeId = wxWindow::NewControlId(); - - wxMenu menu; - menu.Append(useSameOutcomeId, _("Use same outcome here")); - menu.Append(copyOutcomeId, _("Copy outcome here")); - menu.AppendSeparator(); - menu.Append(moveOutcomeId, _("Move outcome here")); - - const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); - - try { - if (selection == useSameOutcomeId) { - m_doc->DoSetOutcome(p_targetNode, p_sourceNode->GetOutcome()); - return true; - } - if (selection == copyOutcomeId) { - m_doc->DoCopyOutcome(p_targetNode, p_sourceNode->GetOutcome()); - return true; - } - if (selection == moveOutcomeId) { - m_doc->DoSetOutcome(p_targetNode, p_sourceNode->GetOutcome()); - m_doc->DoSetOutcome(p_sourceNode, nullptr); - return true; - } - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } - - return false; -} - //--------------------------------------------------------------------- // EfgDisplay: Event-hook members //--------------------------------------------------------------------- -namespace { -GameNode PriorSameInfoset(const GameNode &n) -{ - const GameInfoset infoset = n->GetInfoset(); - if (!infoset) { - return nullptr; - } - const auto members = infoset->GetMembers(); - if (auto node = std::find(members.begin(), members.end(), n); node != members.begin()) { - return *std::prev(node); - } - return nullptr; -} - -GameNode NextSameInfoset(const GameNode &n) -{ - const GameInfoset infoset = n->GetInfoset(); - if (!infoset) { - return nullptr; - } - const auto members = infoset->GetMembers(); - if (auto node = std::find(members.begin(), members.end(), n); - node != members.end() && std::next(node) != members.end()) { - return *std::next(node); - } - return nullptr; -} -} // namespace - void EfgDisplay::OnSize(wxSizeEvent &p_event) { if (m_pendingInitialZoom) { @@ -807,92 +94,17 @@ void EfgDisplay::OnSize(wxSizeEvent &p_event) p_event.Skip(); } -// -// OnKeyEvent -- handle keypress events. -// -// Navigation shortcuts: -// left arrow: go to rendered ancestor of selected node -// right arrow: go to rendered descendant of selected node -// up arrow: go to previous node at the same rendered level -// down arrow: go to next node at the same rendered level -// ALT-up: go to previous member of information set -// ALT-down: go to next member of information set -// space: ensure the selected node is visible -// home: select the root node and make it visible -// -// Editing shortcuts: -// 'M', 'm': edit the move at the selected node -// return/enter: edit the properties of the selected node -// -// Payoff edit mode only: -// escape: cancel edit of payoff -// tab: accept edit of payoff, edit next payoff if any -// -void EfgDisplay::OnKeyEvent(wxKeyEvent &p_event) +void EfgDisplay::OnLeftClick(wxMouseEvent &) { - if (p_event.GetKeyCode() == WXK_HOME) { - m_doc->SetSelectNode(m_doc->GetGame()->GetRoot()); - FocusNode(m_doc->GetSelectNode()); - return; - } - - // After this point, all events involve moving relative to selected node. - // So if there isn't a selected node, the event doesn't apply - const GameNode selectNode = m_doc->GetSelectNode(); - if (!selectNode) { - p_event.Skip(); - return; - } + // Just claims keyboard focus for the canvas (e.g. for scroll/zoom); there's no longer + // any notion of a node being "selected" by a plain click. + SetFocus(); +} - switch (p_event.GetKeyCode()) { - case 'M': - case 'm': { - const wxCommandEvent event(wxEVT_COMMAND_MENU_SELECTED, GBT_MENU_EDIT_MOVE); - wxPostEvent(this, event); - return; - } - case WXK_RETURN: - case WXK_NUMPAD_ENTER: { - const wxCommandEvent event(wxEVT_COMMAND_MENU_SELECTED, GBT_MENU_EDIT_NODE); - wxPostEvent(this, event); - return; - } - case WXK_LEFT: - if (selectNode->GetParent()) { - m_doc->SetSelectNode(selectNode->GetParent()); - EnsureNodeVisible(m_doc->GetSelectNode()); - } - return; - case WXK_RIGHT: - if (selectNode->GetChildren().size() > 0) { - m_doc->SetSelectNode(selectNode->GetChildren().front()); - EnsureNodeVisible(m_doc->GetSelectNode()); - } - return; - case WXK_UP: { - const GameNode prior = ((!p_event.AltDown()) ? m_layout.PriorSameLevel(selectNode) - : PriorSameInfoset(selectNode)); - if (prior) { - m_doc->SetSelectNode(prior); - EnsureNodeVisible(m_doc->GetSelectNode()); - } - return; - } - case WXK_DOWN: { - const GameNode next = - ((!p_event.AltDown()) ? m_layout.NextSameLevel(selectNode) : NextSameInfoset(selectNode)); - if (next) { - m_doc->SetSelectNode(next); - EnsureNodeVisible(m_doc->GetSelectNode()); - } - return; - } - case WXK_SPACE: - EnsureNodeVisible(m_doc->GetSelectNode()); - return; - default: - // If nothing else applies, let event propagate - p_event.Skip(); +void EfgDisplay::OnMagnify(wxMouseEvent &p_event) +{ + if (const double factor = 1.0 + p_event.GetMagnification(); factor > 0.0) { + ZoomByFactor(factor, p_event.GetPosition()); } } @@ -900,37 +112,14 @@ void EfgDisplay::OnKeyEvent(wxKeyEvent &p_event) // EfgDisplay: Implementing GameView members //--------------------------------------------------------------------- -void EfgDisplay::PostPendingChanges() -{ - if (m_outcomeEditor->IsShown()) { - m_outcomeEditor->Commit(); - } -} - void EfgDisplay::OnUpdate() { - // First make sure that the selected node is in fact still valid - if (m_doc->GetSelectNode()) { - auto entry = m_layout.GetNodeEntry(m_doc->GetSelectNode()); - if (!entry) { - m_doc->SetSelectNode(nullptr); - } - } + // The popup may be showing stale data (or a node that no longer exists) after + // whatever changed; the next hover will bring it back. + DismissNodeInfo(); // Force a rebuild on every change for now. RefreshTree(); - - const GameNode selectNode = m_doc->GetSelectNode(); - - m_nodeMenu->Enable(GBT_MENU_EDIT_INSERT_MOVE, static_cast(selectNode)); - m_nodeMenu->Enable(GBT_MENU_EDIT_REVEAL, - selectNode && selectNode->GetInfoset() && - !m_doc->GetGame()->IsAbsentMinded(selectNode->GetInfoset())); - m_nodeMenu->Enable(GBT_MENU_EDIT_DELETE_TREE, selectNode && !selectNode->IsTerminal()); - m_nodeMenu->Enable(GBT_MENU_EDIT_DELETE_PARENT, selectNode && selectNode->GetParent()); - m_nodeMenu->Enable(GBT_MENU_EDIT_REMOVE_OUTCOME, selectNode && selectNode->GetOutcome()); - m_nodeMenu->Enable(GBT_MENU_EDIT_NODE, static_cast(selectNode)); - m_nodeMenu->Enable(GBT_MENU_EDIT_MOVE, selectNode && selectNode->GetInfoset()); } //--------------------------------------------------------------------- @@ -1001,7 +190,7 @@ int ClampZoom(int p_zoom) { return std::clamp(p_zoom, kMinZoom, kMaxZoom); } } // namespace -void EfgDisplay::SetZoom(int p_zoom, bool p_keepSelectionVisible) +void EfgDisplay::SetZoom(int p_zoom) { const int zoom = ClampZoom(p_zoom); if (zoom == m_zoom) { @@ -1010,11 +199,6 @@ void EfgDisplay::SetZoom(int p_zoom, bool p_keepSelectionVisible) m_zoom = zoom; AdjustScrollbarSteps(); - - if (p_keepSelectionVisible) { - EnsureNodeVisible(m_doc->GetSelectNode()); - } - Refresh(); } @@ -1038,7 +222,7 @@ void EfgDisplay::ZoomByFactor(double p_factor, const wxPoint &p_clientPoint) const double layoutX = unscrolledX / oldScale; const double layoutY = unscrolledY / oldScale; - SetZoom(newZoom, false); + SetZoom(newZoom); const double newScale = GetZoom() / 100.0; const int targetUnscrolledX = static_cast(std::lround(layoutX * newScale)); @@ -1074,6 +258,7 @@ void EfgDisplay::OnDraw(wxDC &p_dc) p_dc.SetUserScale(GetScale(), GetScale()); p_dc.Clear(); m_layout.Render(p_dc, false); + DrawDragOverHighlight(p_dc); } void EfgDisplay::OnDraw(wxDC &p_dc, double p_zoom) @@ -1122,175 +307,4 @@ void EfgDisplay::FocusNode(const GameNode &p_node, double p_xFrac, double p_yFra Scroll(clampedX / pixelsPerUnitX, clampedY / pixelsPerUnitY); } - -void EfgDisplay::EnsureNodeVisible(const GameNode &p_node) -{ - if (!p_node) { - return; - } - - int pixelsPerUnitX, pixelsPerUnitY; - GetScrollPixelsPerUnit(&pixelsPerUnitX, &pixelsPerUnitY); - - auto entry = m_layout.GetNodeEntry(p_node); - int xScroll, yScroll; - GetViewStart(&xScroll, &yScroll); - int width, height; - GetClientSize(&width, &height); - - int xx, yy; - CalcScrolledPosition(LayoutToDevice(entry->GetX()) - 20, LayoutToDevice(entry->GetY()), &xx, - &yy); - if (xx < 0) { - xScroll -= -xx / pixelsPerUnitX + 1; - } - - CalcScrolledPosition(LayoutToDevice(entry->GetX()), LayoutToDevice(entry->GetY()), &xx, &yy); - if (xx > width) { - xScroll += (xx - width) / pixelsPerUnitX + 1; - } - if (xScroll < 0) { - xScroll = 0; - } - else if (xScroll > GetScrollRange(wxHORIZONTAL)) { - xScroll = GetScrollRange(wxHORIZONTAL); - } - - CalcScrolledPosition(LayoutToDevice(entry->GetX()), LayoutToDevice(entry->GetY()) - 20, &xx, - &yy); - if (yy < 0) { - yScroll -= -yy / pixelsPerUnitY + 1; - } - CalcScrolledPosition(LayoutToDevice(entry->GetX()), LayoutToDevice(entry->GetY()) + 20, &xx, - &yy); - if (yy > height) { - yScroll += (yy - height) / pixelsPerUnitY + 1; - } - if (yScroll < 0) { - yScroll = 0; - } - else if (yScroll > GetScrollRange(wxVERTICAL)) { - yScroll = GetScrollRange(wxVERTICAL); - } - - Scroll(xScroll, yScroll); -} - -// -// Left mouse button click: -// Without key modifiers, selects a node -// With shift key, selects whole subtree (not yet implemented) -// With control key, adds node to selection (not yet implemented) -// -void EfgDisplay::OnLeftClick(wxMouseEvent &p_event) -{ - SetFocus(); - - int x, y; - CalcUnscrolledPosition(p_event.GetX(), p_event.GetY(), &x, &y); - x = DeviceToLayout(x); - y = DeviceToLayout(y); - - const GameNode node = m_layout.NodeHitTest(x, y); - if (node != m_doc->GetSelectNode()) { - m_doc->SetSelectNode(node); - } -} - -// -// Left mouse button double-click: -// Sets selection, brings up node properties dialog -// -void EfgDisplay::OnLeftDoubleClick(wxMouseEvent &p_event) -{ - using enum HitRegion; - int x, y; - CalcUnscrolledPosition(p_event.GetX(), p_event.GetY(), &x, &y); - x = DeviceToLayout(x); - y = DeviceToLayout(y); - - const auto hit = m_layout.HitTest(x, y); - - if (hit.region == Node) { - m_doc->SetSelectNode(hit.node); - const wxCommandEvent event(wxEVT_COMMAND_MENU_SELECTED, GBT_MENU_EDIT_NODE); - wxPostEvent(this, event); - return; - } - - if (hit.region == Outcome || hit.region == Payoff) { - const int initialPlayer = (hit.region == Payoff) ? hit.payoffPlayer : 0; - m_outcomeEditor->BeginEdit(hit.node, initialPlayer); - return; - } - - const bool aboveClickable = hit.region == BranchAbove && - m_doc->GetStyle().GetBranchAboveLabel() == GBT_BRANCH_LABEL_LABEL; - const bool belowClickable = hit.region == BranchBelow && - m_doc->GetStyle().GetBranchBelowLabel() == GBT_BRANCH_LABEL_LABEL; - if (aboveClickable || belowClickable) { - // hit.node is the child whose incoming branch was hit; the old behavior - // was to select the parent (the node whose move is being edited). - m_doc->SetSelectNode(hit.node->GetParent()); - const wxCommandEvent event(wxEVT_COMMAND_MENU_SELECTED, GBT_MENU_EDIT_MOVE); - wxPostEvent(this, event); - } -} - -void EfgDisplay::OnMagnify(wxMouseEvent &p_event) -{ - if (const double factor = 1.0 + p_event.GetMagnification(); factor > 0.0) { - ZoomByFactor(factor, p_event.GetPosition()); - } -} - -void EfgDisplay::OnMouseMotion(wxMouseEvent &p_event) -{ - using enum HitRegion; - if (p_event.LeftIsDown() && p_event.Dragging()) { - int x, y; - CalcUnscrolledPosition(p_event.GetX(), p_event.GetY(), &x, &y); - x = DeviceToLayout(x); - y = DeviceToLayout(y); - - const auto hit = m_layout.HitTest(x, y); - - if (hit.region == Node && !hit.node->IsTerminal()) { - wxString label; - label << "N" << hit.node->GetNumber(); - wxTextDataObject textData(label); - - wxDropSource source(textData, this); - source.DoDragDrop(wxDrag_DefaultMove); - return; - } - - if ((hit.region == Outcome || hit.region == Payoff) && hit.node->GetOutcome()) { - wxString label; - label << "O" << hit.node->GetNumber(); - wxTextDataObject textData(label); - - wxDropSource source(textData, this); - source.DoDragDrop(wxDrag_DefaultMove); - } - } -} - -// -// Right mouse-button click: -// Set selection, display context-sensitive popup menu -// -void EfgDisplay::OnRightClick(wxMouseEvent &p_event) -{ - int x, y; - CalcUnscrolledPosition(p_event.GetX(), p_event.GetY(), &x, &y); - x = DeviceToLayout(x); - y = DeviceToLayout(y); - - const GameNode node = m_layout.NodeHitTest(x, y); - if (node != m_doc->GetSelectNode()) { - m_doc->SetSelectNode(node); - } - PopupMenu(m_nodeMenu); -} } // namespace Gambit::GUI diff --git a/src/gui/efgdisplay.h b/src/gui/efgdisplay.h index 897ad26172..2731b0103b 100644 --- a/src/gui/efgdisplay.h +++ b/src/gui/efgdisplay.h @@ -23,32 +23,92 @@ #ifndef GAMBIT_GUI_EFGDISPLAY_H #define GAMBIT_GUI_EFGDISPLAY_H +#include + #include "gamedoc.h" #include "efglayout.h" namespace Gambit::GUI { class OutcomeEditorPopup; +class NodeInfoPopup; + +// Number of IDs reserved (starting at GBT_MENU_EDIT_SET_PLAYER_BASE) for +// entries in the node menu's "set player" submenu; one per player, plus chance. +constexpr int gbtSetPlayerMenuCount = 64; class EfgDisplay final : public wxScrolledWindow, public GameView { TreeLayout m_layout; int m_zoom; wxMenu *m_nodeMenu{nullptr}; - OutcomeEditorPopup *m_outcomeEditor; + // The node the right-click (or double-click) node menu was last shown for -- the target + // of whichever of that menu's commands the user picks. Purely local to the tree display; + // there is no persistent "selected node" elsewhere (see UpdateNodeMenu()). + GameNode m_contextNode; + wxMenu *m_setPlayerMenu{nullptr}; + wxMenuItem *m_setPlayerItem{nullptr}; + std::vector m_setPlayerList; + OutcomeEditorPopup *m_outcomeEditor{nullptr}; + NodeInfoPopup *m_nodeInfoPopup{nullptr}; + wxTimer m_hoverTimer; + GameNode m_hoverNode; bool m_pendingInitialZoom{true}; + // Set for the duration of a node move/copy drag (see OnMouseMotion) to the node being + // dragged; used only for live drag-over feedback, not for the drop itself. + GameNode m_draggedNode; + // Likewise, set for the duration of an outcome drag to the node whose outcome is being + // dragged. At most one of m_draggedNode/m_draggedOutcomeNode is set at a time. + GameNode m_draggedOutcomeNode; + // The node currently under the cursor during either kind of drag, and whether dropping + // there is possible, for DrawDragOverHighlight()'s use; null/false when nothing is being + // dragged. + GameNode m_dragOverNode; + bool m_dragOverValid{false}; + + // Constructs m_outcomeEditor/m_nodeInfoPopup (efgtooltip.cc) and installs the tree drop + // target (efgdragdrop.cc), respectively. Called from the constructor body -- rather than + // its initializer list -- since OutcomeEditorPopup/NodeInfoPopup/TreeDropTarget are only + // forward-declared here; InitPopups() must run before OnUpdate(), which dereferences + // m_nodeInfoPopup via DismissNodeInfo(). + void InitPopups(); + void InitDropTarget(); + void MakeMenus(); + // Points m_nodeMenu at p_node (see m_contextNode) and refreshes which of its items are + // enabled accordingly. Called right before the menu is popped up, since nothing else + // keeps it live anymore. + void UpdateNodeMenu(const GameNode &p_node); + void UpdateSetPlayerMenu(); void AdjustScrollbarSteps(); + // Clears the hovered-node tracking and hides the node info popup, if shown. + void DismissNodeInfo(); + // Forwards to m_outcomeEditor->BeginEdit(); lets OnLeftDoubleClick (efgnodemenu.cc) start + // an outcome edit without needing OutcomeEditorPopup's complete type. + void BeginEditOutcome(const GameNode &p_node, int p_initialPlayer = 0); + void DrawDragOverHighlight(wxDC &p_dc); /// @name Event handlers //@{ void OnSize(wxSizeEvent &); void OnMouseMotion(wxMouseEvent &); + void OnLeaveWindow(wxMouseEvent &); void OnLeftClick(wxMouseEvent &); void OnRightClick(wxMouseEvent &); void OnLeftDoubleClick(wxMouseEvent &); void OnMagnify(wxMouseEvent &); - void OnKeyEvent(wxKeyEvent &); + void OnSetPlayerMenu(wxCommandEvent &); + void OnHoverTimer(wxTimerEvent &); + + // Node menu command handlers -- operate on m_contextNode (the node the menu was shown + // for), formerly on GameDocument's now-removed persistent selection. + void OnEditInsertMove(wxCommandEvent &); + void OnEditDeleteTree(wxCommandEvent &); + void OnEditDeleteParent(wxCommandEvent &); + void OnEditRemoveOutcome(wxCommandEvent &); + void OnEditReveal(wxCommandEvent &); + void OnEditNode(wxCommandEvent &); + void OnEditMove(wxCommandEvent &); //@} /// @name Overriding GameView members @@ -71,7 +131,7 @@ class EfgDisplay final : public wxScrolledWindow, public GameView { void OnDraw(wxDC &, double); int GetZoom() const { return m_zoom; } - void SetZoom(int p_zoom, bool p_keepSelectionVisible = true); + void SetZoom(int p_zoom); void FitZoom(); double GetScale() const { return 0.01 * m_zoom; } @@ -83,15 +143,27 @@ class EfgDisplay final : public wxScrolledWindow, public GameView { const TreeLayout &GetLayout() const { return m_layout; } - void EnsureNodeVisible(const GameNode &); - - bool ShowPlayerDropMenu(const GameNode &p_targetNode, const GamePlayer &p_player, - const wxPoint &p_pos); bool ShowTreeDropMenu(const GameNode &p_targetNode, const GameNode &p_sourceNode, const wxPoint &p_pos); bool ShowOutcomeDropMenu(const GameNode &p_targetNode, const GameNode &p_sourceNode, const wxPoint &p_pos); + // Whether dropping a moved/copied subtree rooted at p_source onto p_target is possible + // (some action would be offered); used both to gate ShowTreeDropMenu's contents and to + // give live drag-over feedback while the drag is still in progress. + bool CanDropTreeOn(const GameNode &p_target, const GameNode &p_source) const; + // Whether dropping p_source's outcome onto p_target is possible; used both to gate + // ShowOutcomeDropMenu's contents and for live drag-over feedback. + bool CanDropOutcomeOn(const GameNode &p_target, const GameNode &p_source) const; + // The node being dragged, if a node (tree) move/copy drag is currently in progress; + // null otherwise (including during an outcome drag). + GameNode GetDraggedNode() const { return m_draggedNode; } + // The node whose outcome is being dragged, if an outcome drag is currently in progress; + // null otherwise (including during a node drag). + GameNode GetDraggedOutcomeNode() const { return m_draggedOutcomeNode; } + // Updates the live drag-over highlight (see m_dragOverNode) and repaints if it changed. + void SetDragOverNode(const GameNode &p_node, bool p_valid); + DECLARE_EVENT_TABLE() }; } // namespace Gambit::GUI diff --git a/src/gui/efgdragdrop.cc b/src/gui/efgdragdrop.cc new file mode 100644 index 0000000000..eaa5a785a5 --- /dev/null +++ b/src/gui/efgdragdrop.cc @@ -0,0 +1,408 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/efgdragdrop.cc +// Drag-and-drop of nodes and outcomes within the tree display +// +// 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 "games.h" + +#include "efgdisplay.h" +#include "dlexcept.h" + +namespace Gambit::GUI { + +#include "bitmaps/move.xpm" + +//-------------------------------------------------------------------------- +// class TreeDropTarget +//-------------------------------------------------------------------------- + +class TreeDropTarget : public wxTextDropTarget { + EfgDisplay *m_owner; + GameDocument *m_model; + + bool OnDropOutcome(const GameNode &p_node, const wxString &p_text, const wxPoint &p_pos); + bool OnDropTreeNode(const GameNode &p_node, const wxString &p_text, const wxPoint &p_pos); + +public: + explicit TreeDropTarget(EfgDisplay *p_owner) : m_owner(p_owner), m_model(p_owner->GetDocument()) + { + } + + bool OnDropText(wxCoord x, wxCoord y, const wxString &p_text) override; + // Live feedback while dragging: highlights the node under the cursor as a valid or + // invalid drop target. Only does this for node (tree) drags for now -- outcome drags + // fall through to the default behavior unchanged. + wxDragResult OnDragOver(wxCoord x, wxCoord y, wxDragResult def) override; + void OnLeave() override; +}; + +// +// This recurses the subtree starting at 'p_node' looking for a node +// with the ID 'p_id'. +// +static GameNode GetNode(const GameNode &p_node, int p_id) +{ + if (p_node->GetNumber() == p_id) { + return p_node; + } + if (p_node->IsTerminal()) { + return nullptr; + } + for (const auto &child : p_node->GetChildren()) { + if (const auto node = GetNode(child, p_id)) { + return node; + } + } + return nullptr; +} + +bool TreeDropTarget::OnDropOutcome(const GameNode &p_node, const wxString &p_text, + const wxPoint &p_pos) +{ + long n; + p_text.Right(p_text.Length() - 1).ToLong(&n); + + const GameNode srcNode = GetNode(m_model->GetGame()->GetRoot(), n); + if (!srcNode || srcNode == p_node || !srcNode->GetOutcome()) { + return false; + } + + return m_owner->ShowOutcomeDropMenu(p_node, srcNode, p_pos); +} + +bool TreeDropTarget::OnDropTreeNode(const GameNode &p_node, const wxString &p_text, + const wxPoint &p_pos) +{ + long n; + p_text.Right(p_text.Length() - 1).ToLong(&n); + + const GameNode srcNode = GetNode(m_model->GetGame()->GetRoot(), n); + if (!srcNode || srcNode == p_node || srcNode->IsTerminal()) { + return false; + } + + return m_owner->ShowTreeDropMenu(p_node, srcNode, p_pos); +} + +bool TreeDropTarget::OnDropText(wxCoord p_x, wxCoord p_y, const wxString &p_text) +{ + const Game efg = m_owner->GetDocument()->GetGame(); + + int x, y; + m_owner->CalcUnscrolledPosition(p_x, p_y, &x, &y); + + x = m_owner->DeviceToLayout(x); + y = m_owner->DeviceToLayout(y); + + const GameNode node = m_owner->GetLayout().NodeHitTest(x, y); + if (!node) { + return false; + } + + try { + switch (static_cast(p_text[0])) { + case 'N': + return OnDropTreeNode(node, p_text, wxPoint(p_x, p_y)); + case 'O': + return OnDropOutcome(node, p_text, wxPoint(p_x, p_y)); + default: + return false; + } + } + catch (std::exception &ex) { + ExceptionDialog(m_owner, ex.what()).ShowModal(); + return false; + } +} + +wxDragResult TreeDropTarget::OnDragOver(wxCoord p_x, wxCoord p_y, wxDragResult p_def) +{ + const GameNode treeSource = m_owner->GetDraggedNode(); + const GameNode outcomeSource = m_owner->GetDraggedOutcomeNode(); + if (!treeSource && !outcomeSource) { + // Neither drag is in progress -- shouldn't normally happen, since dragging is the only + // way to get here, but fall back to the default behavior just in case. + return p_def; + } + + int x, y; + m_owner->CalcUnscrolledPosition(p_x, p_y, &x, &y); + x = m_owner->DeviceToLayout(x); + y = m_owner->DeviceToLayout(y); + + const GameNode node = m_owner->GetLayout().NodeHitTest(x, y); + const bool valid = treeSource ? m_owner->CanDropTreeOn(node, treeSource) + : m_owner->CanDropOutcomeOn(node, outcomeSource); + m_owner->SetDragOverNode(node, valid); + return valid ? wxDragMove : wxDragNone; +} + +void TreeDropTarget::OnLeave() { m_owner->SetDragOverNode(nullptr, false); } + +//---------------------------------------------------------------------- +// EfgDisplay: drag-and-drop members +//---------------------------------------------------------------------- + +void EfgDisplay::InitDropTarget() { wxWindow::SetDropTarget(new TreeDropTarget(this)); } + +// True if p_node is p_ancestor itself or lies anywhere below it in the tree. +static bool IsDescendantOf(const GameNode &p_node, const GameNode &p_ancestor) +{ + for (GameNode node = p_node; node; node = node->GetParent()) { + if (node == p_ancestor) { + return true; + } + } + return false; +} + +bool EfgDisplay::CanDropTreeOn(const GameNode &p_target, const GameNode &p_source) const +{ + if (!p_target || !p_source || p_source->IsTerminal() || p_target == p_source) { + return false; + } + // Copying (but not moving -- see ShowTreeDropMenu) onto a node's own descendant is + // well-defined: CopyTree acts on the tree as it stood at the start of the operation, so + // it doesn't recurse. Whether *that* is available is exactly canCopyOrMoveTree below, so + // no separate exclusion is needed here. + const bool canCopyOrMoveTree = p_target->IsTerminal(); + const bool canUseSameInfoset = (!p_target->IsTerminal() && p_target->GetChildren().size() == + p_source->GetChildren().size()) || + p_target->IsTerminal(); + return canCopyOrMoveTree || canUseSameInfoset; +} + +void EfgDisplay::SetDragOverNode(const GameNode &p_node, bool p_valid) +{ + if (p_node == m_dragOverNode && p_valid == m_dragOverValid) { + return; + } + m_dragOverNode = p_node; + m_dragOverValid = p_valid; + Refresh(); +} + +void EfgDisplay::DrawDragOverHighlight(wxDC &p_dc) +{ + if (!m_dragOverNode) { + return; + } + const auto entry = m_layout.GetNodeEntry(m_dragOverNode); + if (!entry) { + return; + } + + constexpr int padding = 10; + const wxColour color = m_dragOverValid ? wxColour(0, 170, 0) : wxColour(210, 0, 0); + p_dc.SetPen(wxPen(color, 3, wxPENSTYLE_SOLID)); + p_dc.SetBrush(*wxTRANSPARENT_BRUSH); + p_dc.DrawEllipse(entry->GetTokenExtent().Inflate(padding, padding)); +} + +bool EfgDisplay::ShowTreeDropMenu(const GameNode &p_targetNode, const GameNode &p_sourceNode, + const wxPoint &p_pos) +{ + if (!CanDropTreeOn(p_targetNode, p_sourceNode)) { + return false; + } + + const bool canCopyTree = p_targetNode->IsTerminal(); + // Moving, unlike copying, is only well-defined when the target isn't part of the + // source's own subtree: CopyTree acts on a snapshot of the tree as it stood at the + // start of the operation, so copying onto a descendant doesn't recurse, but moving + // the subtree there would be asking to attach it to a node that only continues to + // exist as part of the very subtree being relocated. + const bool canMoveTree = canCopyTree && !IsDescendantOf(p_targetNode, p_sourceNode); + const bool canUseSameInfoset = + (!p_targetNode->IsTerminal() && + p_targetNode->GetChildren().size() == p_sourceNode->GetChildren().size()) || + p_targetNode->IsTerminal(); + + const int copyTreeId = wxWindow::NewControlId(); + const int moveTreeId = wxWindow::NewControlId(); + const int infosetId = wxWindow::NewControlId(); + + wxMenu menu; + + if (canCopyTree) { + menu.Append(copyTreeId, _("Copy subtree here")); + menu.Append(moveTreeId, _("Move subtree here")); + menu.Enable(moveTreeId, canMoveTree); + } + + if (canUseSameInfoset) { + if (!menu.GetMenuItems().empty()) { + menu.AppendSeparator(); + } + + if (p_targetNode->IsTerminal()) { + menu.Append(infosetId, _("Insert move using same information set")); + } + else { + menu.Append(infosetId, _("Put node in same information set")); + } + } + + const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); + + try { + if (selection == copyTreeId) { + m_doc->DoCopyTree(p_targetNode, p_sourceNode); + return true; + } + if (selection == moveTreeId) { + m_doc->DoMoveTree(p_targetNode, p_sourceNode); + return true; + } + if (selection == infosetId) { + if (!p_targetNode->IsTerminal()) { + m_doc->DoSetInfoset(p_targetNode, p_sourceNode->GetInfoset()); + } + else { + m_doc->DoAppendMove(p_targetNode, p_sourceNode->GetInfoset()); + } + return true; + } + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } + + return false; +} + +bool EfgDisplay::CanDropOutcomeOn(const GameNode &p_target, const GameNode &p_source) const +{ + return p_target && p_source && p_target != p_source && p_source->GetOutcome(); +} + +bool EfgDisplay::ShowOutcomeDropMenu(const GameNode &p_targetNode, const GameNode &p_sourceNode, + const wxPoint &p_pos) +{ + if (!CanDropOutcomeOn(p_targetNode, p_sourceNode)) { + return false; + } + + const int useSameOutcomeId = wxWindow::NewControlId(); + const int moveOutcomeId = wxWindow::NewControlId(); + + wxMenu menu; + menu.Append(useSameOutcomeId, _("Use same outcome here")); + menu.Append(moveOutcomeId, _("Move outcome here")); + + const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); + + try { + if (selection == useSameOutcomeId) { + m_doc->DoSetOutcome(p_targetNode, p_sourceNode->GetOutcome()); + return true; + } + if (selection == moveOutcomeId) { + m_doc->DoSetOutcome(p_targetNode, p_sourceNode->GetOutcome()); + m_doc->DoSetOutcome(p_sourceNode, nullptr); + return true; + } + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } + + return false; +} + +void EfgDisplay::OnMouseMotion(wxMouseEvent &p_event) +{ + using enum HitRegion; + if (p_event.LeftIsDown() && p_event.Dragging()) { + DismissNodeInfo(); + + int x, y; + CalcUnscrolledPosition(p_event.GetX(), p_event.GetY(), &x, &y); + x = DeviceToLayout(x); + y = DeviceToLayout(y); + + const auto hit = m_layout.HitTest(x, y); + + if (hit.region == Node && !hit.node->IsTerminal()) { + wxString label; + label << "N" << hit.node->GetNumber(); + wxTextDataObject textData(label); + + // Tracked only for live drag-over feedback (CanDropTreeOn/DrawDragOverHighlight); + // the actual drop still resolves its source node independently, by decoding the + // dragged text -- see TreeDropTarget::OnDropTreeNode. + m_draggedNode = hit.node; + // wxDropSource's cursor-bundle parameters are wxIcon on GTK/MSW but wxCursor on + // macOS/Cocoa -- wxDROP_ICON(move) (needing move_xpm in scope, from the #include + // above) is wx's own portable macro for this, expanding to the right type per + // port. On macOS specifically, this ends up with no visible effect regardless: as + // of wxWidgets 3.3, its DoDragDrop ignores the cursor argument entirely and always + // shows a fixed placeholder drag image instead (see src/osx/cocoa/dnd.mm's + // wxDropSource::DoDragDrop, which draws a hardcoded white square with a "TODO: + // proper drag image for data" comment) -- the drag-over highlight + // (DrawDragOverHighlight) compensates there by giving feedback drawn on the canvas + // itself, independent of the native drag image. + wxDropSource source(textData, this, wxDROP_ICON(move), wxDROP_ICON(move)); + source.DoDragDrop(wxDrag_DefaultMove); + m_draggedNode = nullptr; + SetDragOverNode(nullptr, false); + return; + } + + if ((hit.region == Outcome || hit.region == Payoff) && hit.node->GetOutcome()) { + wxString label; + label << "O" << hit.node->GetNumber(); + wxTextDataObject textData(label); + + // Tracked only for live drag-over feedback -- see the node-drag case above. + m_draggedOutcomeNode = hit.node; + wxDropSource source(textData, this); + source.DoDragDrop(wxDrag_DefaultMove); + m_draggedOutcomeNode = nullptr; + SetDragOverNode(nullptr, false); + } + return; + } + + int x, y; + CalcUnscrolledPosition(p_event.GetX(), p_event.GetY(), &x, &y); + x = DeviceToLayout(x); + y = DeviceToLayout(y); + + const auto hit = m_layout.HitTest(x, y); + const GameNode node = (hit.region == Node) ? hit.node : nullptr; + + if (node == m_hoverNode) { + return; + } + + DismissNodeInfo(); + m_hoverNode = node; + if (m_hoverNode) { + m_hoverTimer.StartOnce(400); + } +} +} // namespace Gambit::GUI diff --git a/src/gui/efglayout.cc b/src/gui/efglayout.cc index 92a2167521..ea606276ab 100644 --- a/src/gui/efglayout.cc +++ b/src/gui/efglayout.cc @@ -87,7 +87,7 @@ wxString SafeGenerate(const LabelGenerator &p_generator, const GameNode &p_node) // (if not the root node) // void TreeLayout::DrawNode(wxDC &p_dc, const std::shared_ptr &p_entry, - const GameNode &p_selection, bool p_noHints) const + bool p_noHints) const { const int nodeSize = m_doc->GetStyle().GetNodeSize(); const NodeTokenStyle tokenStyle = GetTokenForNode(m_doc->GetStyle(), p_entry->m_node); @@ -95,8 +95,6 @@ void TreeLayout::DrawNode(wxDC &p_dc, const std::shared_ptr &p_entry, // For every style but "line", this is exactly the shape drawn below -- // computed once by ComputeTokenGeometry, not recomputed here, so drawing // and hit-testing (NodeEntry::NodeHitTest) can never silently disagree. - // "Line" has no real width of its own, so its selection ring keeps its own - // (differently-sized) padding rather than reusing the hit-test region. const wxRect &token = p_entry->m_geometry.token; if (p_entry->m_node->GetParent()) { @@ -104,22 +102,6 @@ void TreeLayout::DrawNode(wxDC &p_dc, const std::shared_ptr &p_entry, } const auto color = m_doc->GetStyle().GetPlayerColor(p_entry->m_node->GetPlayer()); - const bool selected = (p_selection == p_entry->m_node); - constexpr int selectionPadding = 6; - - if (selected) { - p_dc.SetPen(*wxTRANSPARENT_PEN); - p_dc.SetBrush(wxBrush(wxColour(235, 235, 235), wxBRUSHSTYLE_SOLID)); - - if (isLine) { - p_dc.DrawRoundedRectangle( - p_entry->GetX() - selectionPadding, p_entry->GetY() - selectionPadding, - nodeSize + 2 * selectionPadding, 2 * selectionPadding, selectionPadding); - } - else { - p_dc.DrawEllipse(token.Inflate(selectionPadding, selectionPadding)); - } - } p_dc.SetPen(*wxThePenList->FindOrCreatePen(color, 3, wxPENSTYLE_SOLID)); p_dc.SetTextForeground(color); @@ -156,20 +138,6 @@ void TreeLayout::DrawNode(wxDC &p_dc, const std::shared_ptr &p_entry, p_dc.DrawEllipse(token); } - if (selected) { - p_dc.SetBrush(*wxTRANSPARENT_BRUSH); - p_dc.SetPen(*wxThePenList->FindOrCreatePen(*wxBLACK, 1, wxPENSTYLE_SOLID)); - - if (isLine) { - p_dc.DrawRoundedRectangle( - p_entry->GetX() - selectionPadding, p_entry->GetY() - selectionPadding, - nodeSize + 2 * selectionPadding, 2 * selectionPadding, selectionPadding); - } - else { - p_dc.DrawEllipse(token.Inflate(selectionPadding, selectionPadding)); - } - } - int textWidth, textHeight; p_dc.SetFont(m_doc->GetStyle().GetFont()); p_dc.GetTextExtent(p_entry->m_nodeAboveLabel, &textWidth, &textHeight); @@ -544,34 +512,6 @@ HitResult TreeLayout::HitTest(int p_x, int p_y) const return {}; } -GameNode TreeLayout::PriorSameLevel(const GameNode &p_node) const -{ - if (auto entry = GetNodeEntry(p_node)) { - auto e = std::next(std::find(m_nodeList.rbegin(), m_nodeList.rend(), entry)); - while (e != m_nodeList.rend()) { - if ((*e)->GetLevel() == entry->GetLevel()) { - return (*e)->GetNode(); - } - --e; - } - } - return nullptr; -} - -GameNode TreeLayout::NextSameLevel(const GameNode &p_node) const -{ - if (auto entry = GetNodeEntry(p_node)) { - auto e = std::next(std::find(m_nodeList.begin(), m_nodeList.end(), entry)); - while (e != m_nodeList.end()) { - if ((*e)->GetLevel() == entry->GetLevel()) { - return (*e)->GetNode(); - } - ++e; - } - } - return nullptr; -} - std::shared_ptr TreeLayout::ComputeNextInInfoset(const std::shared_ptr &p_entry) const { @@ -712,7 +652,7 @@ void TreeLayout::RenderSubtree(wxDC &p_dc, bool p_noHints) const auto parentEntry = entry->GetParent(); if (entry->GetChildNumber() == 1) { - DrawNode(p_dc, parentEntry, m_doc->GetSelectNode(), p_noHints); + DrawNode(p_dc, parentEntry, p_noHints); if (auto nextMember = ComputeNextInInfoset(parentEntry)) { const int nextY = nextMember->GetY(); @@ -758,7 +698,7 @@ void TreeLayout::RenderSubtree(wxDC &p_dc, bool p_noHints) const } if (entry->GetNode()->IsTerminal()) { - DrawNode(p_dc, entry, m_doc->GetSelectNode(), p_noHints); + DrawNode(p_dc, entry, p_noHints); } } } diff --git a/src/gui/efglayout.h b/src/gui/efglayout.h index 83e01d7ba2..784258e0ab 100644 --- a/src/gui/efglayout.h +++ b/src/gui/efglayout.h @@ -113,6 +113,7 @@ class NodeEntry { const wxRect &GetOutcomeExtent() const { return m_geometry.outcome; } const wxRect &GetPayoffExtent(int pl) const { return m_geometry.payoffs[pl]; } + const wxRect &GetTokenExtent() const { return m_geometry.token; } }; /// What was hit by a point query against the rendered tree, and which node it @@ -169,8 +170,7 @@ class TreeLayout final : public GameView { // Overriding GameView members void OnUpdate() override {} - void DrawNode(wxDC &, const std::shared_ptr &, const GameNode &selection, - bool p_noHints) const; + void DrawNode(wxDC &, const std::shared_ptr &, bool p_noHints) const; void DrawIncomingBranch(wxDC &, const std::shared_ptr &) const; void DrawOutcome(wxDC &, const std::shared_ptr &, bool p_noHints) const; @@ -180,9 +180,6 @@ class TreeLayout final : public GameView { explicit TreeLayout(GameDocument *p_doc) : GameView(p_doc) {} ~TreeLayout() override = default; - GameNode PriorSameLevel(const GameNode &) const; - GameNode NextSameLevel(const GameNode &) const; - void Layout(const Game &); void GenerateLabels() const; diff --git a/src/gui/efgnodemenu.cc b/src/gui/efgnodemenu.cc new file mode 100644 index 0000000000..eb686e0743 --- /dev/null +++ b/src/gui/efgnodemenu.cc @@ -0,0 +1,351 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/efgnodemenu.cc +// The tree display's right-click node menu and its editing commands +// +// 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 "games.h" + +#include "efgdisplay.h" +#include "menuconst.h" +#include "dlexcept.h" +#include "dlinsertmove.h" +#include "dleditnode.h" +#include "dleditmove.h" + +namespace Gambit::GUI { + +void EfgDisplay::MakeMenus() +{ + m_nodeMenu = new wxMenu; + m_nodeMenu->Append(GBT_MENU_EDIT_INSERT_MOVE, _("&Insert move"), _("Insert a move")); + m_nodeMenu->Append(GBT_MENU_EDIT_REVEAL, _("&Reveal"), _("Reveal choice at node")); + + m_setPlayerMenu = new wxMenu; + m_setPlayerItem = m_nodeMenu->AppendSubMenu(m_setPlayerMenu, _("Assign this move to"), + _("Set the player who moves at this node")); + + m_nodeMenu->AppendSeparator(); + + m_nodeMenu->Append(GBT_MENU_EDIT_DELETE_TREE, _("&Delete subtree"), + _("Delete the subtree starting at the selected node")); + m_nodeMenu->Append(GBT_MENU_EDIT_DELETE_PARENT, _("Delete &parent"), + _("Delete the node directly before the selected node")); + m_nodeMenu->Append(GBT_MENU_EDIT_REMOVE_OUTCOME, _("Remove &outcome"), + _("Remove the outcome from the selected node")); + m_nodeMenu->AppendSeparator(); + + m_nodeMenu->Append(GBT_MENU_EDIT_NODE, _("&Node properties"), _("Edit properties of the node")); + m_nodeMenu->Append(GBT_MENU_EDIT_MOVE, _("&Edit move"), _("Edit properties of the move")); + + m_nodeMenu->AppendSeparator(); + m_nodeMenu->Append(GBT_MENU_EDIT_GAME, _("&Game properties"), _("Edit properties of the game")); +} + +void EfgDisplay::UpdateSetPlayerMenu() +{ + while (m_setPlayerMenu->GetMenuItemCount() > 0) { + m_setPlayerMenu->Destroy(m_setPlayerMenu->FindItemByPosition(0)); + } + m_setPlayerList.clear(); + + if (!m_contextNode) { + m_nodeMenu->Enable(m_setPlayerItem->GetId(), false); + return; + } + + const bool terminal = m_contextNode->IsTerminal(); + + // Swapping a node between a chance move and a personal player's move is + // not currently supported, so the operation isn't offered at chance nodes. + if (!terminal && m_contextNode->GetPlayer()->IsChance()) { + m_nodeMenu->Enable(m_setPlayerItem->GetId(), false); + return; + } + m_nodeMenu->Enable(m_setPlayerItem->GetId(), true); + + m_setPlayerItem->SetItemLabel(terminal ? _("Insert move for") : _("Assign this move to")); + + const Game efg = m_doc->GetGame(); + for (const auto &player : efg->GetPlayersWithChance()) { + if (!terminal && player->IsChance()) { + continue; // can't reassign an existing personal player's move to chance + } + if (static_cast(m_setPlayerList.size()) >= gbtSetPlayerMenuCount) { + break; + } + const int id = GBT_MENU_EDIT_SET_PLAYER_BASE + static_cast(m_setPlayerList.size()); + m_setPlayerList.push_back(player); + + wxString label = wxString::FromUTF8(player->GetLabel()); + if (label.empty()) { + label = player->IsChance() ? wxString(_("Chance")) + : wxString::Format(_("Player %d"), player->GetNumber()); + } + + auto *item = new wxMenuItem(m_setPlayerMenu, id, label); + item->SetBitmap(MakeColorSwatch(m_doc->GetStyle().GetPlayerColor(player))); + m_setPlayerMenu->Append(item); + + if (!terminal && m_contextNode->GetPlayer() == player) { + m_setPlayerMenu->Enable(id, false); + } + } +} + +void EfgDisplay::OnSetPlayerMenu(wxCommandEvent &p_event) +{ + if (!m_contextNode) { + return; + } + + const size_t index = p_event.GetId() - GBT_MENU_EDIT_SET_PLAYER_BASE; + if (index >= m_setPlayerList.size()) { + return; + } + const GamePlayer player = m_setPlayerList[index]; + + try { + if (m_contextNode->IsTerminal()) { + m_doc->DoInsertMove(m_contextNode, player, 2); + } + else { + m_doc->DoSetPlayer(m_contextNode, player); + } + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } +} + +void EfgDisplay::UpdateNodeMenu(const GameNode &p_node) +{ + m_contextNode = p_node; + + m_nodeMenu->Enable(GBT_MENU_EDIT_INSERT_MOVE, static_cast(p_node)); + m_nodeMenu->Enable(GBT_MENU_EDIT_REVEAL, + p_node && p_node->GetInfoset() && + !m_doc->GetGame()->IsAbsentMinded(p_node->GetInfoset())); + m_nodeMenu->Enable(GBT_MENU_EDIT_DELETE_TREE, p_node && !p_node->IsTerminal()); + m_nodeMenu->Enable(GBT_MENU_EDIT_DELETE_PARENT, p_node && p_node->GetParent()); + m_nodeMenu->Enable(GBT_MENU_EDIT_REMOVE_OUTCOME, p_node && p_node->GetOutcome()); + m_nodeMenu->Enable(GBT_MENU_EDIT_NODE, static_cast(p_node)); + m_nodeMenu->Enable(GBT_MENU_EDIT_MOVE, p_node && p_node->GetInfoset()); + + UpdateSetPlayerMenu(); +} + +// +// Left mouse button double-click: brings up the properties dialog for whatever was hit. +// +void EfgDisplay::OnLeftDoubleClick(wxMouseEvent &p_event) +{ + using enum HitRegion; + int x, y; + CalcUnscrolledPosition(p_event.GetX(), p_event.GetY(), &x, &y); + x = DeviceToLayout(x); + y = DeviceToLayout(y); + + const auto hit = m_layout.HitTest(x, y); + + if (hit.region == Node) { + m_contextNode = hit.node; + const wxCommandEvent event(wxEVT_COMMAND_MENU_SELECTED, GBT_MENU_EDIT_NODE); + wxPostEvent(this, event); + return; + } + + if (hit.region == Outcome || hit.region == Payoff) { + const int initialPlayer = (hit.region == Payoff) ? hit.payoffPlayer : 0; + BeginEditOutcome(hit.node, initialPlayer); + return; + } + + const bool aboveClickable = hit.region == BranchAbove && + m_doc->GetStyle().GetBranchAboveLabel() == GBT_BRANCH_LABEL_LABEL; + const bool belowClickable = hit.region == BranchBelow && + m_doc->GetStyle().GetBranchBelowLabel() == GBT_BRANCH_LABEL_LABEL; + if (aboveClickable || belowClickable) { + // hit.node is the child whose incoming branch was hit; the old behavior + // was to target the parent (the node whose move is being edited). + m_contextNode = hit.node->GetParent(); + const wxCommandEvent event(wxEVT_COMMAND_MENU_SELECTED, GBT_MENU_EDIT_MOVE); + wxPostEvent(this, event); + } +} + +// +// Right mouse-button click: display the context-sensitive node menu, targeting +// whichever node (if any) was clicked. +// +void EfgDisplay::OnRightClick(wxMouseEvent &p_event) +{ + int x, y; + CalcUnscrolledPosition(p_event.GetX(), p_event.GetY(), &x, &y); + x = DeviceToLayout(x); + y = DeviceToLayout(y); + + UpdateNodeMenu(m_layout.NodeHitTest(x, y)); + PopupMenu(m_nodeMenu); +} + +//--------------------------------------------------------------------- +// EfgDisplay: Node menu command handlers +//--------------------------------------------------------------------- + +// Forward declaration only: defined in dlefgreveal.cc, with no header of its own -- this +// mirrors how it was previously forward-declared in gameframe.cc. +std::optional> RevealMove(wxWindow *p_parent, const Game &p_game); + +void EfgDisplay::OnEditInsertMove(wxCommandEvent &) +{ + InsertMoveDialog dialog(this, m_doc); + if (dialog.ShowModal() == wxID_OK) { + try { + if (dialog.GetInfoset()) { + m_doc->DoInsertMove(m_contextNode, dialog.GetInfoset()); + } + else { + m_doc->DoInsertMove(m_contextNode, dialog.GetPlayer(), dialog.GetActions()); + } + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } + } +} + +void EfgDisplay::OnEditDeleteTree(wxCommandEvent &) +{ + try { + m_doc->DoDeleteTree(m_contextNode); + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } +} + +void EfgDisplay::OnEditDeleteParent(wxCommandEvent &) +{ + try { + m_doc->DoDeleteParent(m_contextNode); + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } +} + +void EfgDisplay::OnEditRemoveOutcome(wxCommandEvent &) +{ + try { + m_doc->DoRemoveOutcome(m_contextNode); + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } +} + +void EfgDisplay::OnEditReveal(wxCommandEvent &) +{ + if (const auto players = RevealMove(this, m_doc->GetGame()); players) { + try { + const auto &infoset = m_contextNode->GetInfoset(); + for (const auto &player : *players) { + m_doc->DoRevealAction(infoset, player); + } + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } + } +} + +void EfgDisplay::OnEditNode(wxCommandEvent &) +{ + EditNodeDialog dialog(this, m_contextNode); + if (dialog.ShowModal() == wxID_OK) { + try { + m_doc->DoSetNodeLabel(m_contextNode, dialog.GetNodeLabel()); + if (dialog.GetOutcome() > 0) { + m_doc->DoSetOutcome(m_contextNode, m_doc->GetGame()->GetOutcome(dialog.GetOutcome())); + } + else { + m_doc->DoSetOutcome(m_contextNode, nullptr); + } + + if (!m_contextNode->IsTerminal() && dialog.GetInfoset() != m_contextNode->GetInfoset()) { + if (dialog.GetInfoset() == nullptr) { + m_doc->DoLeaveInfoset(m_contextNode); + } + else { + m_doc->DoSetInfoset(m_contextNode, dialog.GetInfoset()); + } + } + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } + } +} + +void EfgDisplay::OnEditMove(wxCommandEvent &) +{ + GameInfoset infoset = m_contextNode->GetInfoset(); + if (!infoset) { + return; + } + + EditMoveDialog dialog(this, infoset); + if (dialog.ShowModal() == wxID_OK) { + try { + m_doc->DoSetInfosetLabel(infoset, dialog.GetInfosetLabel()); + + if (!infoset->IsChanceInfoset() && dialog.GetPlayer() != infoset->GetPlayer()->GetNumber()) { + m_doc->DoSetPlayer(infoset, m_doc->GetGame()->GetPlayer(dialog.GetPlayer())); + // DoSetPlayer reforms the members into a brand-new information set (via + // MakeInfoset), invalidating this handle; the node itself is still valid, so + // re-fetch the information set through it. + infoset = m_contextNode->GetInfoset(); + } + + std::vector stableLabels, labels; + std::vector probs; + for (int i = 0; i < dialog.NumActions(); i++) { + if (dialog.IsDeleted(i)) { + continue; + } + stableLabels.push_back(dialog.GetStableLabel(i)); + labels.push_back(dialog.GetActionLabel(i).ToStdString(wxConvUTF8)); + if (infoset->IsChanceInfoset()) { + probs.push_back(dialog.GetActionProb(i)); + } + } + m_doc->DoSetActions(infoset, stableLabels, labels, probs); + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } + } +} +} // namespace Gambit::GUI diff --git a/src/gui/efgpanel.cc b/src/gui/efgpanel.cc index 8f4494d0d8..18abd1e599 100644 --- a/src/gui/efgpanel.cc +++ b/src/gui/efgpanel.cc @@ -25,469 +25,17 @@ #include #ifndef WX_PRECOMP #include -#endif // WX_PRECOMP -#include // for drag-and-drop features -#include // for printing support -#include // for picking player colors -#include // for SVG output +#endif // WX_PRECOMP +#include // for printing support +#include // for SVG output #include "efgpanel.h" -#include "dlexcept.h" #include "efgdisplay.h" // FIXME: communicate with tree window via events. #include "menuconst.h" -#include "edittext.h" namespace Gambit::GUI { -#include "bitmaps/color.xpm" -#include "bitmaps/person.xpm" - -class TreePlayerIcon : public wxStaticBitmap { -private: - int m_player; - - // Event handlers - void OnLeftClick(wxMouseEvent &); - -public: - TreePlayerIcon(wxWindow *p_parent, int p_player); - - DECLARE_EVENT_TABLE() -}; - -BEGIN_EVENT_TABLE(TreePlayerIcon, wxStaticBitmap) -EVT_LEFT_DOWN(TreePlayerIcon::OnLeftClick) -END_EVENT_TABLE() - -TreePlayerIcon::TreePlayerIcon(wxWindow *p_parent, int p_player) - : wxStaticBitmap(p_parent, wxID_ANY, wxBitmap(person_xpm)), m_player(p_player) -{ -} - -void TreePlayerIcon::OnLeftClick(wxMouseEvent &) -{ - wxString label; - label << "P" << m_player; - wxTextDataObject textData(label); - wxDropSource source(textData, this); - source.DoDragDrop(wxDrag_DefaultMove); -} - -class TreePlayerPanel : public wxPanel { -private: - GameDocument *m_doc; - int m_player; - EditableLabelText *m_playerLabel; - wxStaticText *m_payoff, *m_nodeValue, *m_nodeProb; - wxStaticText *m_infosetValue, *m_infosetProb, *m_belief; - - /// @name Event handlers - //@{ - /// The set color icon is clicked - void OnSetColor(wxCommandEvent &); - /// Start the editing of the player label - void OnEditPlayerLabel(wxCommandEvent &); - /// End the editing of the player label after enter is pressed - void OnAcceptPlayerLabel(wxCommandEvent &); - /// Process a keypress event - void OnChar(wxKeyEvent &); - //@} - -public: - TreePlayerPanel(wxWindow *, GameDocument *, int p_player); - - void OnUpdate(); - void PostPendingChanges(); - - DECLARE_EVENT_TABLE() -}; - -BEGIN_EVENT_TABLE(TreePlayerPanel, wxPanel) -EVT_CHAR(TreePlayerPanel::OnChar) -END_EVENT_TABLE() - -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 TreePlayerIcon(this, m_player); - labelSizer->Add(playerIcon, 0, wxALL | wxALIGN_CENTER, 0); - - auto *setColorIcon = new wxBitmapButton(this, wxID_ANY, wxBitmap(color_xpm), wxDefaultPosition, - wxDefaultSize, wxNO_BORDER); - setColorIcon->SetToolTip(_("Change the color for this player")); - - labelSizer->Add(setColorIcon, 0, wxALL | wxALIGN_CENTER, 0); - Connect(setColorIcon->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(TreePlayerPanel::OnSetColor)); - - 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(TreePlayerPanel::OnEditPlayerLabel)); - Connect(m_playerLabel->GetId(), wxEVT_COMMAND_TEXT_ENTER, - wxCommandEventHandler(TreePlayerPanel::OnAcceptPlayerLabel)); - - topSizer->Add(labelSizer, 0, wxALL, 0); - - m_payoff = new wxStaticText(this, wxID_STATIC, wxT("Payoff:"), wxDefaultPosition, wxDefaultSize, - wxALIGN_CENTER | wxST_NO_AUTORESIZE); - m_payoff->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL)); - topSizer->Add(m_payoff, 0, wxALL | wxEXPAND, 0); - topSizer->Show(m_payoff, false); - - m_nodeValue = new wxStaticText(this, wxID_STATIC, wxT("Node value:"), wxDefaultPosition, - wxDefaultSize, wxALIGN_CENTER | wxST_NO_AUTORESIZE); - m_nodeValue->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL)); - topSizer->Add(m_nodeValue, 0, wxALL | wxEXPAND, 0); - topSizer->Show(m_nodeValue, false); - - m_nodeProb = new wxStaticText(this, wxID_STATIC, wxT("Node reached:"), wxDefaultPosition, - wxDefaultSize, wxALIGN_CENTER | wxST_NO_AUTORESIZE); - m_nodeProb->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL)); - topSizer->Add(m_nodeProb, 0, wxALL | wxEXPAND, 0); - topSizer->Show(m_nodeProb, false); - - m_infosetValue = new wxStaticText(this, wxID_STATIC, wxT("Infoset value:"), wxDefaultPosition, - wxDefaultSize, wxALIGN_CENTER | wxST_NO_AUTORESIZE); - m_infosetValue->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL)); - topSizer->Add(m_infosetValue, 0, wxALL | wxEXPAND, 0); - topSizer->Show(m_infosetValue, false); - - m_infosetProb = new wxStaticText(this, wxID_STATIC, wxT("Infoset reached:"), wxDefaultPosition, - wxDefaultSize, wxALIGN_CENTER | wxST_NO_AUTORESIZE); - m_infosetProb->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL)); - topSizer->Add(m_infosetProb, 0, wxALL | wxEXPAND, 0); - topSizer->Show(m_infosetProb, false); - - m_belief = new wxStaticText(this, wxID_STATIC, wxT("Belief:"), wxDefaultPosition, wxDefaultSize, - wxALIGN_CENTER | wxST_NO_AUTORESIZE); - m_belief->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL)); - topSizer->Add(m_belief, 0, wxALL | wxEXPAND, 0); - topSizer->Show(m_belief, false); - - SetSizer(topSizer); - topSizer->SetSizeHints(this); - topSizer->Fit(this); - wxWindowBase::Layout(); - OnUpdate(); -} - -void TreePlayerPanel::OnUpdate() -{ - if (!m_doc->GetGame()->IsTree()) { - return; - } - - const wxColour color = m_doc->GetStyle().GetPlayerColor(m_doc->GetGame()->GetPlayer(m_player)); - - m_playerLabel->SetForegroundColour(color); - m_playerLabel->SetValue(wxString::FromUTF8(m_doc->GetGame()->GetPlayer(m_player)->GetLabel())); - - m_payoff->SetForegroundColour(color); - if (m_doc->GetWorkspace().GetCurrentProfile() > 0) { - const std::string pay = m_doc->GetWorkspace().GetProfiles().GetPayoff(m_player); - m_payoff->SetLabel(wxT("Payoff: ") + wxString::FromUTF8(pay)); - GetSizer()->Show(m_payoff, true); - - if (const GameNode node = m_doc->GetSelectNode()) { - m_nodeValue->SetForegroundColour(color); - std::string value = m_doc->GetWorkspace().GetProfiles().GetNodeValue(node, m_player); - m_nodeValue->SetLabel(wxT("Node value: ") + wxString::FromUTF8(value)); - GetSizer()->Show(m_nodeValue, true); - - if (node->GetInfoset() && node->GetPlayer()->GetNumber() == m_player) { - m_nodeProb->SetForegroundColour(color); - value = m_doc->GetWorkspace().GetProfiles().GetRealizProb(node); - m_nodeProb->SetLabel(wxT("Node reached: ") + wxString::FromUTF8(value)); - GetSizer()->Show(m_nodeProb, true); - - m_infosetValue->SetForegroundColour(color); - value = m_doc->GetWorkspace().GetProfiles().GetInfosetValue(node); - m_infosetValue->SetLabel(wxT("Infoset value: ") + wxString::FromUTF8(value)); - GetSizer()->Show(m_infosetValue, true); - - m_infosetProb->SetForegroundColour(color); - value = m_doc->GetWorkspace().GetProfiles().GetInfosetProb(node); - m_infosetProb->SetLabel(wxT("Infoset reached: ") + wxString::FromUTF8(value)); - GetSizer()->Show(m_infosetProb, true); - - m_belief->SetForegroundColour(color); - value = m_doc->GetWorkspace().GetProfiles().GetBeliefProb(node); - m_belief->SetLabel(wxT("Belief: ") + wxString::FromUTF8(value)); - GetSizer()->Show(m_belief, true); - } - else { - GetSizer()->Show(m_nodeProb, false); - GetSizer()->Show(m_infosetValue, false); - GetSizer()->Show(m_infosetProb, false); - GetSizer()->Show(m_belief, false); - } - } - else { - GetSizer()->Show(m_nodeValue, false); - } - } - else { - GetSizer()->Show(m_payoff, false); - GetSizer()->Show(m_nodeValue, false); - GetSizer()->Show(m_nodeProb, false); - GetSizer()->Show(m_infosetValue, false); - GetSizer()->Show(m_infosetProb, false); - GetSizer()->Show(m_belief, false); - } - - GetSizer()->Layout(); - GetSizer()->SetSizeHints(this); - GetSizer()->Fit(this); -} - -void TreePlayerPanel::OnChar(wxKeyEvent &p_event) -{ - if (p_event.GetKeyCode() == WXK_ESCAPE) { - m_playerLabel->EndEdit(false); - } - else { - p_event.Skip(); - } -} - -void TreePlayerPanel::OnSetColor(wxCommandEvent &) -{ - wxColourData data; - data.SetColour(m_doc->GetStyle().GetPlayerColor(m_doc->GetGame()->GetPlayer(m_player))); - wxColourDialog dialog(this, &data); - wxString label; - label << _("Choose color for player ") << m_player; - dialog.SetTitle(label); - - if (dialog.ShowModal() == wxID_OK) { - const wxColour color = dialog.GetColourData().GetColour(); - TreeRenderConfig style = m_doc->GetStyle(); - style.SetPlayerColor(m_player, color); - m_doc->SetStyle(style); - } -} - -void TreePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) -{ - m_doc->PostPendingChanges(); - m_playerLabel->BeginEdit(); -} - -void TreePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) -{ - const wxString label = LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true); - - if (label.empty()) { - wxBell(); - m_playerLabel->BeginEdit(); - return; - } - - 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() -{ - if (!m_playerLabel->IsEditing()) { - return; - } - m_playerLabel->EndEdit(true); - try { - m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - m_playerLabel->SetValue(wxString::FromUTF8(m_doc->GetGame()->GetPlayer(m_player)->GetLabel())); - } -} - -#include "bitmaps/dice.xpm" - -class gbtTreeChanceIcon : public wxStaticBitmap { -private: - // Event handlers - void OnLeftClick(wxMouseEvent &); - -public: - gbtTreeChanceIcon(wxWindow *p_parent); - - DECLARE_EVENT_TABLE() -}; - -BEGIN_EVENT_TABLE(gbtTreeChanceIcon, wxStaticBitmap) -EVT_LEFT_DOWN(gbtTreeChanceIcon::OnLeftClick) -END_EVENT_TABLE() - -gbtTreeChanceIcon::gbtTreeChanceIcon(wxWindow *p_parent) - : wxStaticBitmap(p_parent, wxID_ANY, wxBitmap(dice_xpm)) -{ -} - -void gbtTreeChanceIcon::OnLeftClick(wxMouseEvent &) -{ - const wxBitmap bitmap(dice_xpm); - -#if defined(__WXMSW__) or defined(__WXMAC__) - const auto image = wxCursor(bitmap.ConvertToImage()); -#else - wxIcon image; - image.CopyFromBitmap(bitmap); -#endif // _WXMSW__ - - wxTextDataObject textData(wxT("P0")); - wxDropSource source(textData, this, image, image, image); - source.DoDragDrop(wxDrag_DefaultMove); -} - -class gbtTreeChancePanel : public wxPanel, public GameView { -private: - wxStaticText *m_playerLabel; - - // Implementation of GameView members - void OnUpdate() override; - - /// @name Event handlers - //@{ - /// The set color icon is clicked - void OnSetColor(wxCommandEvent &); - //@} - -public: - gbtTreeChancePanel(wxWindow *, GameDocument *); -}; - -gbtTreeChancePanel::gbtTreeChancePanel(wxWindow *p_parent, GameDocument *p_doc) - : wxPanel(p_parent, wxID_ANY), GameView(p_doc) -{ - auto *topSizer = new wxBoxSizer(wxVERTICAL); - - auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); - - wxStaticBitmap *playerIcon = new gbtTreeChanceIcon(this); - labelSizer->Add(playerIcon, 0, wxALL | wxALIGN_CENTER, 0); - - auto *setColorIcon = new wxBitmapButton(this, wxID_ANY, wxBitmap(color_xpm), wxDefaultPosition, - wxDefaultSize, wxNO_BORDER); - setColorIcon->SetToolTip(_("Change the color for this player")); - - labelSizer->Add(setColorIcon, 0, wxALL | wxALIGN_CENTER, 0); - Connect(setColorIcon->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(gbtTreeChancePanel::OnSetColor)); - - m_playerLabel = new wxStaticText(this, wxID_STATIC, wxT("Chance"), wxDefaultPosition, - wxSize(125, -1), wxALIGN_LEFT); - m_playerLabel->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); - labelSizer->Add(m_playerLabel, 1, wxLEFT | wxALIGN_CENTER, 10); - - topSizer->Add(labelSizer, 0, wxALL, 0); - - SetSizer(topSizer); - topSizer->SetSizeHints(this); - topSizer->Fit(this); - wxWindowBase::Layout(); -} - -void gbtTreeChancePanel::OnUpdate() -{ - if (!m_doc->GetGame()) { - return; - } - - m_playerLabel->SetForegroundColour(m_doc->GetStyle().ChanceColor()); - GetSizer()->Layout(); -} - -void gbtTreeChancePanel::OnSetColor(wxCommandEvent &) -{ - wxColourData data; - data.SetColour(m_doc->GetStyle().ChanceColor()); - wxColourDialog dialog(this, &data); - dialog.SetTitle(wxT("Choose color for chance player")); - - if (dialog.ShowModal() == wxID_OK) { - const wxColour color = dialog.GetColourData().GetColour(); - TreeRenderConfig style = m_doc->GetStyle(); - style.SetChanceColor(color); - m_doc->SetStyle(style); - } -} - -//===================================================================== -// class gbtTreePlayerToolbar -//===================================================================== - -class gbtTreePlayerToolbar : public wxPanel, public GameView { -private: - gbtTreeChancePanel *m_chancePanel; - Array m_playerPanels; - - // @name Implementation of GameView members - //@{ - void OnUpdate() override; - void PostPendingChanges() override; - //@} - -public: - gbtTreePlayerToolbar(wxWindow *p_parent, GameDocument *p_doc); -}; - -gbtTreePlayerToolbar::gbtTreePlayerToolbar(wxWindow *p_parent, GameDocument *p_doc) - : wxPanel(p_parent, wxID_ANY, wxDefaultPosition, wxSize(210, -1)), GameView(p_doc), - m_chancePanel(new gbtTreeChancePanel(this, m_doc)) -{ - auto *topSizer = new wxBoxSizer(wxVERTICAL); - - topSizer->Add(m_chancePanel, 0, wxALL | wxEXPAND, 5); - - for (size_t pl = 1; pl <= m_doc->GetGame()->NumPlayers(); pl++) { - m_playerPanels.push_back(new TreePlayerPanel(this, m_doc, pl)); - topSizer->Add(m_playerPanels[pl], 0, wxALL | wxEXPAND, 5); - } - - SetSizer(topSizer); - wxWindowBase::Layout(); -} - -void gbtTreePlayerToolbar::OnUpdate() -{ - while (m_playerPanels.size() < m_doc->GetGame()->NumPlayers()) { - 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()) { - 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(&TreePlayerPanel::OnUpdate)); - GetSizer()->Layout(); -} - -void gbtTreePlayerToolbar::PostPendingChanges() -{ - std::for_each(m_playerPanels.begin(), m_playerPanels.end(), - std::mem_fn(&TreePlayerPanel::PostPendingChanges)); -} - //===================================================================== // Implementation of class EfgPanel //===================================================================== @@ -500,16 +48,10 @@ EVT_MENU(GBT_MENU_VIEW_ZOOMFIT, EfgPanel::OnViewZoomFit) END_EVENT_TABLE() EfgPanel::EfgPanel(wxWindow *p_parent, GameDocument *p_doc) - : wxPanel(p_parent, wxID_ANY), GameView(p_doc), m_treeWindow(new EfgDisplay(this, m_doc)), - m_playerToolbar(new gbtTreePlayerToolbar(this, m_doc)) + : wxPanel(p_parent, wxID_ANY), GameView(p_doc), m_treeWindow(new EfgDisplay(this, m_doc)) { auto *topSizer = new wxBoxSizer(wxVERTICAL); - - auto *treeSizer = new wxBoxSizer(wxHORIZONTAL); - treeSizer->Add(m_playerToolbar, 0, wxEXPAND, 0); - treeSizer->Add(m_treeWindow, 1, wxEXPAND, 0); - - topSizer->Add(treeSizer, 1, wxEXPAND, 0); + topSizer->Add(m_treeWindow, 1, wxEXPAND, 0); SetSizer(topSizer); wxWindowBase::Layout(); } diff --git a/src/gui/efgpanel.h b/src/gui/efgpanel.h index b249593355..b2bf2c6638 100644 --- a/src/gui/efgpanel.h +++ b/src/gui/efgpanel.h @@ -32,7 +32,6 @@ class EfgDisplay; class EfgPanel final : public wxPanel, public GameView { EfgDisplay *m_treeWindow; - wxWindow *m_playerToolbar; // Overriding GameView members void OnUpdate() override {} diff --git a/src/gui/efgprofile.cc b/src/gui/efgprofile.cc index de034d7396..f596a3bbfb 100644 --- a/src/gui/efgprofile.cc +++ b/src/gui/efgprofile.cc @@ -61,12 +61,6 @@ void MixedBehaviorProfileList::OnLabelClick(wxGridEvent &p_event) if (p_event.GetCol() == -1) { m_doc->DoSelectProfile(p_event.GetRow() + 1); } - else { - // Clicking on an action column sets the selected node to the first - // member of that information set. - const GameAction action = m_doc->GetAction(p_event.GetCol() + 1); - m_doc->SetSelectNode(action->GetInfoset()->GetMember(1)); - } ClearSelection(); } diff --git a/src/gui/efgtooltip.cc b/src/gui/efgtooltip.cc new file mode 100644 index 0000000000..dd3d4d09ba --- /dev/null +++ b/src/gui/efgtooltip.cc @@ -0,0 +1,617 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/efgtooltip.cc +// Popups for editing a node's outcome and for showing hover information +// +// 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 +#include +#ifndef WX_PRECOMP +#include +#endif // WX_PRECOMP +#include + +#include "games.h" + +#include "efgdisplay.h" +#include "valnumber.h" + +namespace Gambit::GUI { + +class OutcomeEditorPopup : public wxPopupTransientWindow { +public: + OutcomeEditorPopup(EfgDisplay *p_owner, GameDocument *p_doc); + + void BeginEdit(const GameNode &p_node, int p_initialPlayer = 0); + bool Commit(); + void Cancel(); + +protected: + 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; + GameDocument *m_doc; + + GameNode m_node; + + 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_errorText(nullptr) +{ + SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + + BuildControls(); + + Bind(wxEVT_CHAR_HOOK, &OutcomeEditorPopup::OnKeyDown, this); +} + +void OutcomeEditorPopup::BuildControls() +{ + auto *popupSizer = new wxBoxSizer(wxVERTICAL); + + m_contentPanel = new wxPanel(this); + m_contentPanel->SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + auto *outerSizer = new wxBoxSizer(wxVERTICAL); + + auto *heading = new wxStaticText(m_contentPanel, wxID_ANY, _("Outcome")); + + wxFont headingFont = heading->GetFont(); + headingFont.SetWeight(wxFONTWEIGHT_BOLD); + headingFont.SetPointSize(headingFont.GetPointSize() + 1); + heading->SetFont(headingFont); + + outerSizer->Add(heading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); + + auto *labelSizer = new wxFlexGridSizer(2, FromDIP(7), FromDIP(12)); + labelSizer->AddGrowableCol(1, 1); + + labelSizer->Add(new wxStaticText(m_contentPanel, wxID_ANY, _("Label")), 0, + wxALIGN_CENTER_VERTICAL); + + m_labelCtrl = new wxTextCtrl(m_contentPanel, wxID_ANY); + m_labelCtrl->SetMinSize(wxSize(FromDIP(180), -1)); + labelSizer->Add(m_labelCtrl, 1, wxEXPAND); + + outerSizer->Add(labelSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); + + auto *payoffHeading = new wxStaticText(m_contentPanel, wxID_ANY, _("Payoffs")); + + wxFont payoffHeadingFont = payoffHeading->GetFont(); + payoffHeadingFont.SetWeight(wxFONTWEIGHT_BOLD); + payoffHeading->SetFont(payoffHeadingFont); + + outerSizer->Add(payoffHeading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); + + auto *payoffSizer = new wxFlexGridSizer(2, FromDIP(7), FromDIP(12)); + payoffSizer->AddGrowableCol(1, 1); + + const Game game = m_doc->GetGame(); + + for (size_t player = 1; player <= m_doc->GetGame()->NumPlayers(); ++player) { + const GamePlayer gamePlayer = game->GetPlayer(player); + + payoffSizer->Add( + new wxStaticText(m_contentPanel, wxID_ANY, wxString::FromUTF8(gamePlayer->GetLabel())), 0, + wxALIGN_CENTER_VERTICAL); + + auto *payoffCtrl = new wxTextCtrl(m_contentPanel, wxID_ANY); + + payoffCtrl->SetValidator(NumberValidator(nullptr)); + payoffCtrl->SetMinSize(wxSize(FromDIP(100), -1)); + + payoffSizer->Add(payoffCtrl, 1, wxEXPAND); + m_payoffCtrls.push_back(payoffCtrl); + } + + outerSizer->Add(payoffSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | 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)); + + SetSizerAndFit(popupSizer); +} + +void OutcomeEditorPopup::LoadValues() +{ + const GameOutcome outcome = m_node ? m_node->GetOutcome() : nullptr; + + if (!outcome) { + m_labelCtrl->Clear(); + + for (auto *ctrl : m_payoffCtrls) { + ctrl->SetValue(wxT("0")); + } + + return; + } + + m_labelCtrl->SetValue(wxString::FromUTF8(outcome->GetLabel())); + + const Game game = m_doc->GetGame(); + + for (size_t player = 1; player <= m_payoffCtrls.size(); ++player) { + const std::string payoff = outcome->GetPayoff(game->GetPlayer(player)); + + m_payoffCtrls[player - 1]->SetValue(wxString(payoff.c_str(), *wxConvCurrent)); + } +} + +void OutcomeEditorPopup::PositionPopup() +{ + auto entry = m_owner->GetLayout().GetNodeEntry(m_node); + if (!entry) { + return; + } + + int clientX, clientY; + m_owner->CalcScrolledPosition(m_owner->LayoutToDevice(entry->GetX() + 20), + m_owner->LayoutToDevice(entry->GetY()), &clientX, &clientY); + + const wxPoint screenPoint = m_owner->ClientToScreen(wxPoint(clientX, clientY)); + + Position(screenPoint, wxSize(FromDIP(8), FromDIP(8))); +} + +void OutcomeEditorPopup::BeginEdit(const GameNode &p_node, int p_initialPlayer) +{ + m_node = p_node; + m_initialPlayer = p_initialPlayer; + m_cancelled = false; + m_dismissing = false; + m_committing = false; + m_restoringAfterFailedCommit = false; + + LoadValues(); + ClearValidationFailure(); + Fit(); + PositionPopup(); + + Popup(); + + if (m_initialPlayer > 0 && m_initialPlayer <= static_cast(m_payoffCtrls.size())) { + wxTextCtrl *ctrl = m_payoffCtrls[m_initialPlayer - 1]; + ctrl->SetFocus(); + ctrl->SelectAll(); + } + else { + m_labelCtrl->SetFocus(); + m_labelCtrl->SetInsertionPointEnd(); + } +} + +void OutcomeEditorPopup::OnDismiss() +{ + if (m_dismissing || m_restoringAfterFailedCommit) { + return; + } + + if (m_cancelled) { + m_cancelled = false; + m_node = nullptr; + return; + } + + Commit(); +} + +void OutcomeEditorPopup::OnKeyDown(wxKeyEvent &p_event) +{ + switch (p_event.GetKeyCode()) { + case WXK_ESCAPE: + Cancel(); + return; + + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + Commit(); + return; + + default: + p_event.Skip(); + } +} + +void OutcomeEditorPopup::Cancel() +{ + if (!m_node) { + return; + } + + ClearValidationFailure(); + + m_cancelled = true; + Dismiss(); +} + +OutcomeEditorPopup::ValidationResult +OutcomeEditorPopup::ValidatePayoffs(std::vector &p_payoffs) +{ + p_payoffs.clear(); + p_payoffs.reserve(m_payoffCtrls.size()); + + 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 &) { + return {false, + wxString::Format(_("Payoff for player %lu is not a valid number."), + static_cast(player)), + ctrl}; + } + + 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) { + 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::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(); + + if (!IsShown()) { + Popup(); + } + + wxTextCtrl *ctrl = p_invalidCtrl ? p_invalidCtrl : m_labelCtrl; + ctrl->SetFocus(); + ctrl->SelectAll(); + }); +} +//-------------------------------------------------------------------------- +// class NodeInfoPopup +//-------------------------------------------------------------------------- + +// A read-only tooltip-like popup showing, for a hovered node, each player's +// expected continuation value from that node, plus -- for a nonterminal node +// -- the mover's realization/belief probabilities and infoset value. Replaces +// the always-visible per-player equilibrium readout that used to live in the +// left-hand player panel. +class NodeInfoPopup : public wxPopupTransientWindow { +public: + NodeInfoPopup(EfgDisplay *p_owner, GameDocument *p_doc); + + void ShowForNode(const GameNode &p_node); + +private: + void BuildControls(); + void PositionPopup(); + + EfgDisplay *m_owner; + GameDocument *m_doc; + + GameNode m_node; + + wxPanel *m_contentPanel; + + struct PlayerRow { + wxStaticText *nameText; + wxStaticText *valueText; + }; + std::vector m_playerRows; + + wxBoxSizer *m_moveSizer; + wxStaticText *m_moveHeading; + wxStaticText *m_nodeProbText; + wxStaticText *m_infosetValueText; + wxStaticText *m_infosetProbText; + wxStaticText *m_beliefText; +}; + +NodeInfoPopup::NodeInfoPopup(EfgDisplay *p_owner, GameDocument *p_doc) + : wxPopupTransientWindow(p_owner, wxBORDER_NONE), m_owner(p_owner), m_doc(p_doc) +{ + SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + BuildControls(); +} + +void NodeInfoPopup::BuildControls() +{ + auto *popupSizer = new wxBoxSizer(wxVERTICAL); + + m_contentPanel = new wxPanel(this); + m_contentPanel->SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + auto *outerSizer = new wxBoxSizer(wxVERTICAL); + + auto *playerGridSizer = new wxFlexGridSizer(2, FromDIP(4), FromDIP(12)); + + const Game game = m_doc->GetGame(); + for (size_t pl = 1; pl <= game->NumPlayers(); pl++) { + PlayerRow row; + + row.nameText = new wxStaticText(m_contentPanel, wxID_ANY, + wxString::FromUTF8(game->GetPlayer(pl)->GetLabel())); + wxFont nameFont = row.nameText->GetFont(); + nameFont.SetWeight(wxFONTWEIGHT_BOLD); + row.nameText->SetFont(nameFont); + playerGridSizer->Add(row.nameText, 0, wxALIGN_CENTER_VERTICAL); + + row.valueText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + playerGridSizer->Add(row.valueText, 0, wxALIGN_CENTER_VERTICAL); + + m_playerRows.push_back(row); + } + + outerSizer->Add(playerGridSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); + + m_moveSizer = new wxBoxSizer(wxVERTICAL); + + m_moveHeading = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + wxFont headingFont = m_moveHeading->GetFont(); + headingFont.SetWeight(wxFONTWEIGHT_BOLD); + m_moveHeading->SetFont(headingFont); + m_moveSizer->Add(m_moveHeading, 0, wxTOP, FromDIP(8)); + + m_nodeProbText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_moveSizer->Add(m_nodeProbText, 0, wxTOP, FromDIP(2)); + m_infosetProbText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_moveSizer->Add(m_infosetProbText, 0, wxTOP, FromDIP(2)); + m_beliefText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_moveSizer->Add(m_beliefText, 0, wxTOP, FromDIP(2)); + m_infosetValueText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_moveSizer->Add(m_infosetValueText, 0, wxTOP, FromDIP(2)); + + outerSizer->Add(m_moveSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(12)); + + m_contentPanel->SetSizer(outerSizer); + popupSizer->Add(m_contentPanel, 1, wxEXPAND | wxALL, FromDIP(1)); + SetSizerAndFit(popupSizer); +} + +void NodeInfoPopup::PositionPopup() +{ + auto entry = m_owner->GetLayout().GetNodeEntry(m_node); + if (!entry) { + return; + } + + int clientX, clientY; + m_owner->CalcScrolledPosition(m_owner->LayoutToDevice(entry->GetX() + 20), + m_owner->LayoutToDevice(entry->GetY()), &clientX, &clientY); + + const wxPoint screenPoint = m_owner->ClientToScreen(wxPoint(clientX, clientY)); + + Position(screenPoint, wxSize(FromDIP(8), FromDIP(8))); +} + +void NodeInfoPopup::ShowForNode(const GameNode &p_node) +{ + m_node = p_node; + + const Game game = m_doc->GetGame(); + const AnalysisOutput &profiles = m_doc->GetWorkspace().GetProfiles(); + + for (size_t pl = 1; pl <= m_playerRows.size(); pl++) { + const GamePlayer player = game->GetPlayer(pl); + const wxColour color = m_doc->GetStyle().GetPlayerColor(player); + PlayerRow &row = m_playerRows[pl - 1]; + + row.nameText->SetForegroundColour(color); + row.valueText->SetForegroundColour(color); + row.valueText->SetLabel( + wxString::FromUTF8(profiles.GetNodeValue(p_node, static_cast(pl)))); + } + + const bool showMove = !p_node->IsTerminal(); + if (showMove) { + const GamePlayer mover = p_node->GetPlayer(); + const wxColour moverColor = m_doc->GetStyle().GetPlayerColor(mover); + + m_moveHeading->SetForegroundColour(moverColor); + m_moveHeading->SetLabel(wxString::FromUTF8(mover->GetLabel()) + _(" to move")); + m_nodeProbText->SetLabel(_("Pr(Node reached): ") + + wxString::FromUTF8(profiles.GetRealizProb(p_node))); + m_infosetProbText->SetLabel(_("Pr(Infoset reached): ") + + wxString::FromUTF8(profiles.GetInfosetProb(p_node))); + m_beliefText->SetLabel(_("Belief: ") + wxString::FromUTF8(profiles.GetBeliefProb(p_node))); + m_infosetValueText->SetLabel(_("Payoff | Infoset: ") + + wxString::FromUTF8(profiles.GetInfosetValue(p_node))); + } + m_contentPanel->GetSizer()->Show(m_moveSizer, showMove, true); + + m_contentPanel->GetSizer()->Layout(); + Fit(); + PositionPopup(); + Popup(); +} + +//---------------------------------------------------------------------- +// EfgDisplay: outcome/tooltip popup members +//---------------------------------------------------------------------- + +void EfgDisplay::InitPopups() +{ + m_outcomeEditor = new OutcomeEditorPopup(this, m_doc); + m_nodeInfoPopup = new NodeInfoPopup(this, m_doc); +} + +void EfgDisplay::PostPendingChanges() +{ + if (m_outcomeEditor->IsShown()) { + m_outcomeEditor->Commit(); + } +} + +void EfgDisplay::BeginEditOutcome(const GameNode &p_node, int p_initialPlayer) +{ + m_outcomeEditor->BeginEdit(p_node, p_initialPlayer); +} + +void EfgDisplay::DismissNodeInfo() +{ + m_hoverTimer.Stop(); + m_hoverNode = nullptr; + if (m_nodeInfoPopup->IsShown()) { + m_nodeInfoPopup->Dismiss(); + } +} + +void EfgDisplay::OnHoverTimer(wxTimerEvent &) +{ + if (!m_hoverNode || m_doc->GetWorkspace().GetCurrentProfile() <= 0) { + return; + } + m_nodeInfoPopup->ShowForNode(m_hoverNode); +} + +void EfgDisplay::OnLeaveWindow(wxMouseEvent &p_event) +{ + DismissNodeInfo(); + p_event.Skip(); +} +} // namespace Gambit::GUI diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index b49bd4289a..2a5c377a5f 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -204,8 +204,7 @@ bool AnalysisWorkspace::Load(const std::vector &p //========================================================================= GameDocument::GameDocument(Game p_game) - : m_game(p_game), m_selectNode(nullptr), m_gameModified(false), m_workspaceModified(false), - m_workspace(this) + : m_game(p_game), m_gameModified(false), m_workspaceModified(false), m_workspace(this) { wxGetApp().AddDocument(this); @@ -358,12 +357,6 @@ void GameDocument::DoTopDominanceLevel() UpdateViews(); } -void GameDocument::SetSelectNode(GameNode p_node) -{ - m_selectNode = p_node; - UpdateViews(); -} - //====================================================================== // Commands for model part of MVC architecture start here. //====================================================================== @@ -712,17 +705,6 @@ void GameDocument::DoRemoveOutcome(GameNode p_node) NotifyChanged(GameModificationType::GamePayoffs); } -void GameDocument::DoCopyOutcome(GameNode p_node, GameOutcome p_outcome) -{ - const GameOutcome outcome = m_game->NewOutcome(GenerateOutcomeLabel(m_game)); - outcome->SetLabel("Outcome" + lexical_cast(outcome->GetNumber())); - for (const auto &player : m_game->GetPlayers()) { - outcome->SetPayoff(player, p_outcome->GetPayoff(player)); - } - m_game->SetOutcome(p_node, outcome); - NotifyChanged(GameModificationType::GamePayoffs); -} - void GameDocument::DoSetPayoff(GameOutcome p_outcome, int p_player, const wxString &p_value) { p_outcome->SetPayoff(m_game->GetPlayer(p_player), Number(p_value.ToStdString())); diff --git a/src/gui/gamedoc.h b/src/gui/gamedoc.h index 67af9d43a7..2a004a1b5c 100644 --- a/src/gui/gamedoc.h +++ b/src/gui/gamedoc.h @@ -223,7 +223,6 @@ class GameDocument { wxString m_filename; TreeRenderConfig m_style; - GameNode m_selectNode; bool m_gameModified, m_workspaceModified; AnalysisWorkspace m_workspace; @@ -276,9 +275,6 @@ class GameDocument { void DoPreviousDominanceLevel(); void DoTopDominanceLevel(); - GameNode GetSelectNode() const { return m_selectNode; } - void SetSelectNode(GameNode); - /// Call to ask viewers to post any pending changes void PostPendingChanges(); @@ -343,7 +339,6 @@ class GameDocument { void DoSetOutcomeData(const GameNode &p_node, const wxString &p_label, const std::vector &p_payoffs); void DoRemoveOutcome(GameNode p_node); - void DoCopyOutcome(GameNode p_node, GameOutcome p_outcome); void DoSetPayoff(GameOutcome p_outcome, int p_player, const wxString &p_value); void DoAnalysisOutputChanged(); diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index 04cd8f750f..f65902e4f4 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -55,9 +55,6 @@ #include "dlefglogit.h" #include "dlabout.h" -#include "dlinsertmove.h" -#include "dleditnode.h" -#include "dleditmove.h" #include "dlefglayout.h" #include "dlefglegend.h" #include "dlnewtable.h" @@ -199,13 +196,6 @@ EVT_MENU(wxID_PREVIEW, GameFrame::OnFilePrintPreview) EVT_MENU(wxID_PRINT, GameFrame::OnFilePrint) EVT_MENU(wxID_EXIT, GameFrame::OnFileExit) EVT_MENU_RANGE(wxID_FILE1, wxID_FILE9, GameFrame::OnFileMRUFile) -EVT_MENU(GBT_MENU_EDIT_INSERT_MOVE, GameFrame::OnEditInsertMove) -EVT_MENU(GBT_MENU_EDIT_DELETE_TREE, GameFrame::OnEditDeleteTree) -EVT_MENU(GBT_MENU_EDIT_DELETE_PARENT, GameFrame::OnEditDeleteParent) -EVT_MENU(GBT_MENU_EDIT_REMOVE_OUTCOME, GameFrame::OnEditRemoveOutcome) -EVT_MENU(GBT_MENU_EDIT_REVEAL, GameFrame::OnEditReveal) -EVT_MENU(GBT_MENU_EDIT_NODE, GameFrame::OnEditNode) -EVT_MENU(GBT_MENU_EDIT_MOVE, GameFrame::OnEditMove) EVT_MENU(GBT_MENU_EDIT_GAME, GameFrame::OnEditGame) EVT_MENU(GBT_MENU_EDIT_NEWPLAYER, GameFrame::OnEditNewPlayer) EVT_MENU(GBT_MENU_VIEW_PROFILES, GameFrame::OnViewProfiles) @@ -319,19 +309,8 @@ void GameFrame::OnUpdate() SetTitle(GetTitle() + wxT(" (unsaved changes)")); } - const GameNode selectNode = m_doc->GetSelectNode(); wxMenuBar *menuBar = GetMenuBar(); - menuBar->Enable(GBT_MENU_EDIT_INSERT_MOVE, selectNode != nullptr); - menuBar->Enable(GBT_MENU_EDIT_REVEAL, - selectNode && selectNode->GetInfoset() && - !m_doc->GetGame()->IsAbsentMinded(selectNode->GetInfoset())); - menuBar->Enable(GBT_MENU_EDIT_DELETE_TREE, selectNode && !selectNode->IsTerminal()); - menuBar->Enable(GBT_MENU_EDIT_DELETE_PARENT, selectNode && selectNode->GetParent()); - menuBar->Enable(GBT_MENU_EDIT_REMOVE_OUTCOME, selectNode && selectNode->GetOutcome()); - menuBar->Enable(GBT_MENU_EDIT_NODE, selectNode != nullptr); - menuBar->Enable(GBT_MENU_EDIT_MOVE, selectNode && selectNode->GetInfoset()); - GetToolBar()->EnableTool(GBT_MENU_EDIT_NEWPLAYER, !m_efgPanel || m_efgPanel->IsShown()); menuBar->Enable(GBT_MENU_VIEW_PROFILES, m_doc->GetWorkspace().NumProfileLists() > 0); @@ -454,22 +433,6 @@ void GameFrame::MakeMenus() AppendBitmapItem(editMenu, GBT_MENU_EDIT_NEWPLAYER, _("Add p&layer"), _("Add a new player to the game"), wxBitmap(newplayer_xpm)); - editMenu->AppendSeparator(); - editMenu->Append(GBT_MENU_EDIT_INSERT_MOVE, _("&Insert move"), _("Insert a move")); - editMenu->Append(GBT_MENU_EDIT_REVEAL, _("&Reveal"), _("Reveal choice at node")); - editMenu->AppendSeparator(); - - editMenu->Append(GBT_MENU_EDIT_DELETE_TREE, _("&Delete subtree"), - _("Delete the subtree starting at the selected node")); - editMenu->Append(GBT_MENU_EDIT_DELETE_PARENT, _("Delete &parent"), - _("Delete the node directly before the selected node")); - editMenu->Append(GBT_MENU_EDIT_REMOVE_OUTCOME, _("Remove &outcome"), - _("Remove the outcome from the selected node")); - editMenu->AppendSeparator(); - - editMenu->Append(GBT_MENU_EDIT_NODE, _("&Node"), _("Edit properties of the node")); - editMenu->Append(GBT_MENU_EDIT_MOVE, _("&Move"), _("Edit properties of the move")); - editMenu->AppendSeparator(); editMenu->Append(GBT_MENU_EDIT_GAME, _("&Game"), _("Edit properties of the game")); @@ -911,134 +874,24 @@ void GameFrame::OnFileMRUFile(wxCommandEvent &p_event) // GameFrame: Menu handlers - Edit menu //---------------------------------------------------------------------- -void GameFrame::OnEditInsertMove(wxCommandEvent &) -{ - InsertMoveDialog dialog(this, m_doc); - if (dialog.ShowModal() == wxID_OK) { - try { - if (dialog.GetInfoset()) { - m_doc->DoInsertMove(m_doc->GetSelectNode(), dialog.GetInfoset()); - } - else { - m_doc->DoInsertMove(m_doc->GetSelectNode(), dialog.GetPlayer(), dialog.GetActions()); - } - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } - } -} - -void GameFrame::OnEditDeleteTree(wxCommandEvent &) -{ - try { - m_doc->DoDeleteTree(m_doc->GetSelectNode()); - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } -} - -void GameFrame::OnEditDeleteParent(wxCommandEvent &) -{ - try { - m_doc->DoDeleteParent(m_doc->GetSelectNode()); - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } -} - -void GameFrame::OnEditRemoveOutcome(wxCommandEvent &) -{ - try { - m_doc->DoRemoveOutcome(m_doc->GetSelectNode()); - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } -} - -std::optional> RevealMove(wxWindow *p_parent, const Game &p_game); - -void GameFrame::OnEditReveal(wxCommandEvent &) -{ - if (const auto players = RevealMove(this, m_doc->GetGame()); players) { - try { - const auto &infoset = m_doc->GetSelectNode()->GetInfoset(); - for (const auto &player : *players) { - m_doc->DoRevealAction(infoset, player); - } - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } - } -} - -void GameFrame::OnEditNode(wxCommandEvent &) -{ - EditNodeDialog dialog(this, m_doc->GetSelectNode()); - if (dialog.ShowModal() == wxID_OK) { - try { - m_doc->DoSetNodeLabel(m_doc->GetSelectNode(), dialog.GetNodeLabel()); - if (dialog.GetOutcome() > 0) { - m_doc->DoSetOutcome(m_doc->GetSelectNode(), - m_doc->GetGame()->GetOutcome(dialog.GetOutcome())); - } - else { - m_doc->DoSetOutcome(m_doc->GetSelectNode(), nullptr); - } - - if (!m_doc->GetSelectNode()->IsTerminal() && - dialog.GetInfoset() != m_doc->GetSelectNode()->GetInfoset()) { - if (dialog.GetInfoset() == nullptr) { - m_doc->DoLeaveInfoset(m_doc->GetSelectNode()); - } - else { - m_doc->DoSetInfoset(m_doc->GetSelectNode(), dialog.GetInfoset()); - } - } - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } - } -} - -void GameFrame::OnEditMove(wxCommandEvent &) +void GameFrame::OnEditGame(wxCommandEvent &) { - GameInfoset infoset = m_doc->GetSelectNode()->GetInfoset(); - if (!infoset) { - return; - } - - EditMoveDialog dialog(this, infoset); + GamePropertiesDialog dialog(this, m_doc); if (dialog.ShowModal() == wxID_OK) { try { - m_doc->DoSetInfosetLabel(infoset, dialog.GetInfosetLabel()); - - if (!infoset->IsChanceInfoset() && dialog.GetPlayer() != infoset->GetPlayer()->GetNumber()) { - m_doc->DoSetPlayer(infoset, m_doc->GetGame()->GetPlayer(dialog.GetPlayer())); - // DoSetPlayer reforms the members into a brand-new information set (via - // MakeInfoset), invalidating this handle; the node itself is still valid, so - // re-fetch the information set through it. - infoset = m_doc->GetSelectNode()->GetInfoset(); - } - - std::vector stableLabels, labels; - std::vector probs; - for (int i = 0; i < dialog.NumActions(); i++) { - if (dialog.IsDeleted(i)) { - continue; - } - stableLabels.push_back(dialog.GetStableLabel(i)); - labels.push_back(dialog.GetActionLabel(i).ToStdString(wxConvUTF8)); - if (infoset->IsChanceInfoset()) { - probs.push_back(dialog.GetActionProb(i)); + m_doc->DoSetTitle(dialog.GetTitle(), dialog.GetDescription()); + + TreeRenderConfig style = m_doc->GetStyle(); + for (int i = 0; i < dialog.NumRows(); i++) { + const GamePlayer player = dialog.GetPlayer(i); + if (player->IsChance()) { + style.SetChanceColor(dialog.GetPlayerColor(i)); + continue; // chance's label is reserved and can't be changed } + m_doc->DoSetPlayerLabel(player, dialog.GetPlayerLabel(i)); + style.SetPlayerColor(player->GetNumber(), dialog.GetPlayerColor(i)); } - m_doc->DoSetActions(infoset, stableLabels, labels, probs); + m_doc->SetStyle(style); } catch (std::exception &ex) { ExceptionDialog(this, ex.what()).ShowModal(); @@ -1046,14 +899,6 @@ void GameFrame::OnEditMove(wxCommandEvent &) } } -void GameFrame::OnEditGame(wxCommandEvent &) -{ - GamePropertiesDialog dialog(this, m_doc); - if (dialog.ShowModal() == wxID_OK) { - m_doc->DoSetTitle(dialog.GetTitle(), dialog.GetDescription()); - } -} - void GameFrame::OnEditNewPlayer(wxCommandEvent &) { try { diff --git a/src/gui/gameframe.h b/src/gui/gameframe.h index 189c924220..1c4131f851 100644 --- a/src/gui/gameframe.h +++ b/src/gui/gameframe.h @@ -66,14 +66,6 @@ class GameFrame final : public wxFrame, public GameView { void OnFileExit(wxCommandEvent &); void OnFileMRUFile(wxCommandEvent &); - void OnEditInsertMove(wxCommandEvent &); - void OnEditDeleteTree(wxCommandEvent &); - void OnEditDeleteParent(wxCommandEvent &); - void OnEditRemoveOutcome(wxCommandEvent &); - void OnEditReveal(wxCommandEvent &); - - void OnEditNode(wxCommandEvent &); - void OnEditMove(wxCommandEvent &); void OnEditGame(wxCommandEvent &); void OnEditNewPlayer(wxCommandEvent &); diff --git a/src/gui/menuconst.h b/src/gui/menuconst.h index 321895d3d5..1923f4c998 100644 --- a/src/gui/menuconst.h +++ b/src/gui/menuconst.h @@ -43,6 +43,10 @@ enum MenuItems { GBT_MENU_EDIT_NODE = 1420, GBT_MENU_EDIT_MOVE = 1421, + // Reserves a block of IDs for the "set player" submenu on the node + // context menu, one per player (plus chance); see EfgDisplay::UpdateSetPlayerMenu. + GBT_MENU_EDIT_SET_PLAYER_BASE = 1430, + GBT_MENU_EDIT_NEWPLAYER = 1500, GBT_MENU_VIEW_PROFILES = 1850, diff --git a/src/gui/nfgheadergrid.cc b/src/gui/nfgheadergrid.cc new file mode 100644 index 0000000000..15f979c6f9 --- /dev/null +++ b/src/gui/nfgheadergrid.cc @@ -0,0 +1,968 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/nfgheadergrid.cc +// Row/column player header grids for the strategic game matrix display +// +// 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 + +#include +#ifndef WX_PRECOMP +#include +#endif // WX_PRECOMP +#include + +#include + +#include "gamedoc.h" +#include "nfgtable.h" +#include "dlexcept.h" + +namespace Gambit::GUI { + +//========================================================================= +// class RowHeaderCellRenderer +//========================================================================= + +//! +//! Draws the row-player header cells: default string rendering, plus a +//! diagonal dominance-indicator overlay when applicable. +//! +class RowHeaderCellRenderer final : public wxGridCellStringRenderer { + TableWidget *m_table; + +public: + explicit RowHeaderCellRenderer(TableWidget *p_table) : m_table(p_table) {} + + void Draw(wxGrid &grid, wxGridCellAttr &attr, wxDC &dc, const wxRect &rect, int row, int col, + bool isSelected) override + { + wxGridCellStringRenderer::Draw(grid, attr, dc, rect, row, col, isSelected); + + if (!m_table->ShowDominance() || m_table->GetRowHeaderColCount() == 0) { + return; + } + + if (m_table->IsRowHeaderStrategyDominated(col, row, false)) { + const int player = m_table->GetRowHeaderPlayer(col); + if (m_table->IsRowHeaderStrategyDominated(col, row, true)) { + dc.SetPen(wxPen(m_table->GetPlayerColor(player), 2, wxPENSTYLE_SOLID)); + } + else { + dc.SetPen(wxPen(m_table->GetPlayerColor(player), 1, wxPENSTYLE_SHORT_DASH)); + } + dc.DrawLine(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height); + dc.DrawLine(rect.x + rect.width, rect.y, rect.x, rect.y + rect.height); + } + } + + wxGridCellRenderer *Clone() const override { return new RowHeaderCellRenderer(m_table); } +}; + +//========================================================================= +// class RowPlayerTable +//========================================================================= + +class RowPlayerTable final : public wxGridTableBase { + TableWidget *m_table; + int m_numRows{0}; + int m_numCols{0}; + RowHeaderCellRenderer *m_renderer; + +public: + explicit RowPlayerTable(TableWidget *p_table) + : m_table(p_table), m_renderer(new RowHeaderCellRenderer(p_table)) + { + // Freshly-constructed wxGridCellWorker-derived objects start unreferenced; + // this is our own table's stake, matched by DecRef() in the destructor. + // Each SetRenderer() call below additionally consumes one reference per + // call (that's the ownership contract of that setter), so GetAttr() takes + // a fresh IncRef() immediately before each one -- the instance itself is + // reused across all cells and all calls, which is required: wxGrid + // expects the *same* renderer object identity back on repeated queries + // for a given cell. + m_renderer->IncRef(); + } + + ~RowPlayerTable() override { m_renderer->DecRef(); } + + int GetNumberRows() override { return m_numRows; } + int GetNumberCols() override { return m_numCols; } + bool IsEmptyCell(int, int) override { return false; } + wxString GetValue(int row, int col) override; + void SetValue(int row, int col, const wxString &value) override; + wxGridCellAttr *GetAttr(int row, int col, wxGridCellAttr::wxAttrKind kind) override; + + /// Reconciles row/col count and span pattern with the current document state. + void OnUpdate(); +}; + +wxString RowPlayerTable::GetValue(int row, int col) +{ + if (m_table->GetRowHeaderColCount() == 0) { + return wxT("Payoffs"); + } + + const int player = m_table->GetRowHeaderPlayer(col); + const int strat = m_table->GetRowHeaderStrategy(col, row); + return wxString::FromUTF8(m_table->GetStrategyByPlayerAndIndex(player, strat)->GetLabel()); +} + +void RowPlayerTable::SetValue(int, int, const wxString &) +{ + // Row header cells are read-only: editing a player's strategies goes entirely through + // TableWidget::EditStrategies(), triggered on left-click rather than through wxGrid's own + // cell-editing machinery. This override exists only to satisfy the pure virtual + // wxGridTableBase interface. +} + +wxGridCellAttr *RowPlayerTable::GetAttr(int row, int col, wxGridCellAttr::wxAttrKind) +{ + auto *attr = new wxGridCellAttr(); + attr->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); + attr->SetAlignment(wxALIGN_CENTRE, wxALIGN_CENTRE); + // wxGridCellStringRenderer's default is to let text that's too wide spill + // into a neighbouring cell if that cell is otherwise empty -- wrong here, + // since the "neighbour" past a span boundary is a different strategy's + // header cell, not empty space. + attr->SetOverflow(false); + attr->SetReadOnly(true); + + if (m_table->GetRowHeaderColCount() > 0) { + attr->SetTextColour(m_table->GetPlayerColor(m_table->GetRowHeaderPlayer(col))); + } + else { + attr->SetTextColour(*wxBLACK); + } + + // GetAttr() constructs a fresh attr on every call, so the span has to be + // computed and set directly here rather than via wxGrid::SetCellSize() + // -- that call (and GetCellSize()) themselves go through GetAttr(), which + // would recurse infinitely if this override tried to consult it. Only + // the anchor (first row of each span group) gets a non-trivial size; + // interior rows are left at the default 1x1. Computed unconditionally + // (matching ColPlayerTable::GetAttr) rather than gated on + // GetRowHeaderColCount() > 0, for the same reason: don't rely on a + // formula's edge-case behaviour matching whichever branch happens to + // call it. + const int span = m_table->GetRowHeaderRowSpan(col); + if (span > 1 && row % span == 0) { + attr->SetSize(span, 1); + } + + attr->SetBackgroundColour(*wxLIGHT_GREY); + m_renderer->IncRef(); + attr->SetRenderer(m_renderer); + + return attr; +} + +void RowPlayerTable::OnUpdate() +{ + wxGrid *view = GetView(); + + const int newRows = m_table->GetRowHeaderRowCount(); + if (newRows > m_numRows) { + const int added = newRows - m_numRows; + m_numRows = newRows; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_APPENDED, added); + view->ProcessTableMessage(msg); + } + } + else if (newRows < m_numRows) { + const int removed = m_numRows - newRows; + const int pos = newRows; + m_numRows = newRows; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_DELETED, pos, removed); + view->ProcessTableMessage(msg); + } + } + + const int newCols = std::max(m_table->GetRowHeaderColCount(), 1); + if (newCols > m_numCols) { + const int added = newCols - m_numCols; + m_numCols = newCols; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_APPENDED, added); + view->ProcessTableMessage(msg); + } + } + else if (newCols < m_numCols) { + const int removed = m_numCols - newCols; + const int pos = newCols; + m_numCols = newCols; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_DELETED, pos, removed); + view->ProcessTableMessage(msg); + } + } + + // Spans are computed directly in GetAttr() (see the comment there), so + // there's no separate SetCellSize() pass needed here -- just repaint. + if (view) { + view->Refresh(); + } +} + +namespace { +wxString GetStrategyDescription(const GameStrategy &p_strategy) +{ + if (!p_strategy->GetGame()->IsTree()) { + return {}; + } + + wxString description; + for (const auto &infoset : p_strategy->GetPlayer()->GetInfosets()) { + const auto action = p_strategy->GetAction(infoset); + if (!action) { + continue; + } + + if (!description.empty()) { + description << wxT("\n"); + } + description << wxString::Format(_("Information set %d"), infoset->GetNumber()); + if (!infoset->GetLabel().empty()) { + description << wxT(" (") << wxString(infoset->GetLabel().c_str(), *wxConvCurrent) + << wxT(")"); + } + description << wxT(": "); + if (action->GetLabel().empty()) { + description << wxString::Format(_("action %d"), action->GetNumber()); + } + else { + description << wxString(action->GetLabel().c_str(), *wxConvCurrent); + } + } + return description; +} + +// The strategy's index in the flattened, game-wide numbering used by +// AnalysisOutput::GetStrategyProb/GetStrategyValue (players in Game::GetPlayers() order, +// each contributing their own strategies in order) -- distinct from +// GameStrategy::GetNumber(), which is only the index within its own player. +int GetGlobalStrategyIndex(const GameStrategy &p_strategy) +{ + int index = 0; + for (const auto &player : p_strategy->GetGame()->GetPlayers()) { + if (player == p_strategy->GetPlayer()) { + break; + } + index += static_cast(player->GetStrategies().size()); + } + return index + p_strategy->GetNumber(); +} + +bool HasStrategyInfo(GameDocument *p_doc, const GameStrategy &p_strategy) +{ + return !GetStrategyDescription(p_strategy).empty() || + p_doc->GetWorkspace().GetCurrentProfile() > 0; +} + +// A hover tooltip for a row/col header's strategy cell: the strategy's tree-derived +// description (if the nfg is derived from a tree), and -- when a profile is selected -- +// its probability and expected payoff under the current profile (the classic "value of +// this pure strategy against the others' current play" readout used to check best +// responses), plus its player's own overall current-profile payoff for context. Built +// once and re-populated on each hover, mirroring NodeInfoPopup in efgtooltip.cc. +class StrategyInfoPopup final : public wxPopupTransientWindow { +public: + explicit StrategyInfoPopup(wxWindow *p_parent) : wxPopupTransientWindow(p_parent, wxBORDER_NONE) + { + SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + BuildControls(); + } + + void ShowForStrategy(GameDocument *p_doc, const GameStrategy &p_strategy, + const wxPoint &p_anchor) + { + const wxString label = wxString::FromUTF8(p_strategy->GetLabel()); + m_heading->SetLabel(wxString::Format(_("Strategy %s"), label)); + + const wxString description = GetStrategyDescription(p_strategy); + m_description->SetLabel(description); + m_contentPanel->GetSizer()->Show(m_description, !description.empty(), true); + + const bool hasProfile = p_doc->GetWorkspace().GetCurrentProfile() > 0; + if (hasProfile) { + const AnalysisOutput &profiles = p_doc->GetWorkspace().GetProfiles(); + const int index = GetGlobalStrategyIndex(p_strategy); + const GamePlayer player = p_strategy->GetPlayer(); + const wxColour color = p_doc->GetStyle().GetPlayerColor(player); + + wxString playerLabel = wxString::FromUTF8(player->GetLabel()); + if (playerLabel.empty()) { + playerLabel = wxString::Format(_("Player %d"), player->GetNumber()); + } + + m_probText->SetForegroundColour(color); + m_probText->SetLabel(_("Pr(played): ") + + wxString::FromUTF8(profiles.GetStrategyProb(index))); + m_valueText->SetForegroundColour(color); + m_valueText->SetLabel(_("Payoff if played: ") + + wxString::FromUTF8(profiles.GetStrategyValue(index))); + m_payoffText->SetForegroundColour(color); + m_payoffText->SetLabel(playerLabel + _(" payoff: ") + + wxString::FromUTF8(profiles.GetPayoff(player->GetNumber()))); + } + m_contentPanel->GetSizer()->Show(m_profileSizer, hasProfile, true); + + m_contentPanel->GetSizer()->Layout(); + Fit(); + Position(p_anchor, wxSize(FromDIP(8), FromDIP(8))); + Popup(); + } + +private: + void BuildControls() + { + auto *popupSizer = new wxBoxSizer(wxVERTICAL); + + m_contentPanel = new wxPanel(this); + m_contentPanel->SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + auto *outerSizer = new wxBoxSizer(wxVERTICAL); + + m_heading = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + wxFont headingFont = m_heading->GetFont(); + headingFont.SetWeight(wxFONTWEIGHT_BOLD); + headingFont.SetPointSize(headingFont.GetPointSize() + 1); + m_heading->SetFont(headingFont); + outerSizer->Add(m_heading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); + + m_description = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_description->Wrap(FromDIP(420)); + outerSizer->Add(m_description, 0, wxALL, FromDIP(12)); + + m_profileSizer = new wxBoxSizer(wxVERTICAL); + m_probText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_profileSizer->Add(m_probText, 0, wxTOP, FromDIP(2)); + m_valueText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_profileSizer->Add(m_valueText, 0, wxTOP, FromDIP(2)); + m_payoffText = new wxStaticText(m_contentPanel, wxID_ANY, wxEmptyString); + m_profileSizer->Add(m_payoffText, 0, wxTOP, FromDIP(2)); + outerSizer->Add(m_profileSizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(12)); + + m_contentPanel->SetSizer(outerSizer); + popupSizer->Add(m_contentPanel, 1, wxEXPAND | wxALL, FromDIP(1)); + SetSizerAndFit(popupSizer); + } + + wxPanel *m_contentPanel; + wxStaticText *m_heading; + wxStaticText *m_description; + wxBoxSizer *m_profileSizer; + wxStaticText *m_probText; + wxStaticText *m_valueText; + wxStaticText *m_payoffText; +}; + +} // namespace + +//========================================================================= +// class RowPlayerGrid +//========================================================================= + +class RowPlayerGrid final : public TableGridBase { + TableWidget *m_table; + RowPlayerTable *m_gridTable; + StrategyInfoPopup *m_infoPopup; + wxTimer m_hoverTimer; + int m_hoverRow{-1}, m_hoverCol{-1}; + + void OnCellLeftClick(wxGridEvent &) override; + void OnCellRightClick(wxGridEvent &); + void OnMotion(wxMouseEvent &); + void OnLeaveWindow(wxMouseEvent &); + void OnHoverTimer(wxTimerEvent &); + void DismissInfo(); + + void ShowPlacementMenu(int p_col, const wxPoint &p_pos); + +public: + explicit RowPlayerGrid(TableWidget *p_parent); + + void OnUpdate() { m_gridTable->OnUpdate(); } +}; + +RowPlayerGrid::RowPlayerGrid(TableWidget *p_parent) + : TableGridBase(p_parent, wxID_ANY), m_table(p_parent), + m_gridTable(new RowPlayerTable(p_parent)), m_infoPopup(new StrategyInfoPopup(this)) +{ + SetTable(m_gridTable, true); + SetGridLineColour(*wxBLACK); + EnableScrolling(false, false); + ShowScrollbars(wxSHOW_SB_NEVER, wxSHOW_SB_NEVER); + // EnableScrolling(false, false) doesn't stop wxGrid's own mouse-wheel + // handling, which bypasses the base wxScrolledWindow gate -- this pane + // must only ever move in response to the payoff grid's scroll sync. + Bind(wxEVT_MOUSEWHEEL, [](wxMouseEvent &) {}); + + Bind(wxEVT_GRID_CELL_RIGHT_CLICK, &RowPlayerGrid::OnCellRightClick, this); + // wxGrid is a composite widget -- plain (non-command) mouse events like these are + // delivered to the internal grid window, not this outer wxGrid object, so they have to + // be bound there instead. wxEVT_GRID_CELL_*_CLICK don't have this problem: those are + // wxGridEvents, which wxGrid itself generates and dispatches to the outer object. + GetGridWindow()->Bind(wxEVT_MOTION, &RowPlayerGrid::OnMotion, this); + GetGridWindow()->Bind(wxEVT_LEAVE_WINDOW, &RowPlayerGrid::OnLeaveWindow, this); + m_hoverTimer.SetOwner(this); + Bind(wxEVT_TIMER, &RowPlayerGrid::OnHoverTimer, this); +} + +void RowPlayerGrid::OnCellLeftClick(wxGridEvent &p_event) +{ + DismissInfo(); + + if (m_table->GetRowHeaderColCount() == 0) { + TableGridBase::OnCellLeftClick(p_event); + return; + } + + if (m_table->IsReadOnly()) { + return; + } + + m_table->EditStrategies(m_table->GetRowHeaderPlayer(p_event.GetCol())); +} + +void RowPlayerGrid::OnCellRightClick(wxGridEvent &p_event) +{ + DismissInfo(); + ShowPlacementMenu(p_event.GetCol(), p_event.GetPosition()); +} + +void RowPlayerGrid::OnMotion(wxMouseEvent &p_event) +{ + if (m_table->GetRowHeaderColCount() > 0) { + const wxGridCellCoords coords = XYToCell(p_event.GetPosition()); + if (coords.GetRow() != m_hoverRow || coords.GetCol() != m_hoverCol) { + DismissInfo(); + m_hoverRow = coords.GetRow(); + m_hoverCol = coords.GetCol(); + if (m_hoverRow >= 0 && m_hoverCol >= 0) { + m_hoverTimer.StartOnce(400); + } + } + } + p_event.Skip(); +} + +void RowPlayerGrid::OnLeaveWindow(wxMouseEvent &p_event) +{ + DismissInfo(); + p_event.Skip(); +} + +void RowPlayerGrid::DismissInfo() +{ + m_hoverTimer.Stop(); + m_hoverRow = m_hoverCol = -1; + if (m_infoPopup->IsShown()) { + m_infoPopup->Dismiss(); + } +} + +void RowPlayerGrid::OnHoverTimer(wxTimerEvent &) +{ + if (m_hoverRow < 0 || m_hoverCol < 0) { + return; + } + const int player = m_table->GetRowHeaderPlayer(m_hoverCol); + const int strategy = m_table->GetRowHeaderStrategy(m_hoverCol, m_hoverRow); + const auto gameStrategy = m_table->GetStrategyByPlayerAndIndex(player, strategy); + GameDocument *doc = m_table->GetDocument(); + if (!HasStrategyInfo(doc, gameStrategy)) { + return; + } + const wxRect cellRect = CellToRect(m_hoverRow, m_hoverCol); + const wxPoint anchor = GetGridWindow()->ClientToScreen(cellRect.GetBottomLeft()); + m_infoPopup->ShowForStrategy(doc, gameStrategy, anchor); +} + +// Right-clicking a row-header cell offers to place any player at this position (before +// or after the player currently occupying it), replacing what used to be a drag of a +// player icon from the (now-removed) left-hand player panel -- that never worked very +// well, and didn't survive the panel's removal anyway. When there are no row players yet, +// the only choice is which player to use as the (single) row player. +void RowPlayerGrid::ShowPlacementMenu(int p_col, const wxPoint &p_pos) +{ + const bool hasRowPlayers = m_table->GetRowHeaderColCount() > 0; + const int beforeIndex = hasRowPlayers ? p_col + 1 : 1; + const int afterIndex = beforeIndex + 1; + + wxMenu menu; + wxMenu *beforeMenu = hasRowPlayers ? new wxMenu : nullptr; + wxMenu *afterMenu = hasRowPlayers ? new wxMenu : nullptr; + wxMenu *useMenu = hasRowPlayers ? nullptr : new wxMenu; + std::map> placements; // menu id -> (index, player) + + for (const auto &player : m_table->GetDocument()->GetGame()->GetPlayers()) { + wxString label = wxString::FromUTF8(player->GetLabel()); + if (label.empty()) { + label = wxString::Format(_("Player %d"), player->GetNumber()); + } + const wxBitmap swatch = MakeColorSwatch(m_table->GetPlayerColor(player->GetNumber())); + + if (!hasRowPlayers) { + const int id = wxWindow::NewControlId(); + auto *item = new wxMenuItem(useMenu, id, label); + item->SetBitmap(swatch); + useMenu->Append(item); + placements[id] = {1, player->GetNumber()}; + continue; + } + + if (!m_table->IsRowPlayerPlacementNoOp(beforeIndex, player->GetNumber())) { + const int id = wxWindow::NewControlId(); + auto *item = new wxMenuItem(beforeMenu, id, label); + item->SetBitmap(swatch); + beforeMenu->Append(item); + placements[id] = {beforeIndex, player->GetNumber()}; + } + if (!m_table->IsRowPlayerPlacementNoOp(afterIndex, player->GetNumber())) { + const int id = wxWindow::NewControlId(); + auto *item = new wxMenuItem(afterMenu, id, label); + item->SetBitmap(swatch); + afterMenu->Append(item); + placements[id] = {afterIndex, player->GetNumber()}; + } + } + + if (hasRowPlayers) { + if (beforeMenu->GetMenuItemCount() > 0) { + menu.AppendSubMenu(beforeMenu, _("Place player before")); + } + else { + delete beforeMenu; + } + if (afterMenu->GetMenuItemCount() > 0) { + menu.AppendSubMenu(afterMenu, _("Place player after")); + } + else { + delete afterMenu; + } + } + else { + menu.AppendSubMenu(useMenu, _("Use as row player")); + } + + if (menu.GetMenuItemCount() == 0) { + return; + } + + const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); + const auto it = placements.find(selection); + if (it == placements.end()) { + return; + } + + try { + m_table->SetRowPlayer(it->second.first, it->second.second); + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } +} + +//========================================================================= +// class ColHeaderCellRenderer +//========================================================================= + +//! +//! Draws the column-player header cells: default string rendering, plus a +//! diagonal dominance-indicator overlay when applicable. +//! +class ColHeaderCellRenderer final : public wxGridCellStringRenderer { + TableWidget *m_table; + +public: + explicit ColHeaderCellRenderer(TableWidget *p_table) : m_table(p_table) {} + + void Draw(wxGrid &grid, wxGridCellAttr &attr, wxDC &dc, const wxRect &rect, int row, int col, + bool isSelected) override + { + wxGridCellStringRenderer::Draw(grid, attr, dc, rect, row, col, isSelected); + + if (!m_table->ShowDominance() || m_table->GetColHeaderRowCount() == 0) { + return; + } + + if (m_table->IsColHeaderStrategyDominated(row, col, false)) { + const int player = m_table->GetColHeaderPlayer(row); + if (m_table->IsColHeaderStrategyDominated(row, col, true)) { + dc.SetPen(wxPen(m_table->GetPlayerColor(player), 2, wxPENSTYLE_SOLID)); + } + else { + dc.SetPen(wxPen(m_table->GetPlayerColor(player), 1, wxPENSTYLE_SHORT_DASH)); + } + dc.DrawLine(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height); + dc.DrawLine(rect.x + rect.width, rect.y, rect.x, rect.y + rect.height); + } + } + + wxGridCellRenderer *Clone() const override { return new ColHeaderCellRenderer(m_table); } +}; + +//========================================================================= +// class ColPlayerTable +//========================================================================= + +class ColPlayerTable final : public wxGridTableBase { + TableWidget *m_table; + int m_numRows{0}; + int m_numCols{0}; + ColHeaderCellRenderer *m_renderer; + +public: + explicit ColPlayerTable(TableWidget *p_table) + : m_table(p_table), m_renderer(new ColHeaderCellRenderer(p_table)) + { + m_renderer->IncRef(); + } + + ~ColPlayerTable() override { m_renderer->DecRef(); } + + int GetNumberRows() override { return m_numRows; } + int GetNumberCols() override { return m_numCols; } + bool IsEmptyCell(int, int) override { return false; } + wxString GetValue(int row, int col) override; + void SetValue(int row, int col, const wxString &value) override; + wxGridCellAttr *GetAttr(int row, int col, wxGridCellAttr::wxAttrKind kind) override; + + void OnUpdate(); +}; + +wxString ColPlayerTable::GetValue(int row, int col) +{ + if (m_table->GetColHeaderRowCount() == 0) { + return wxT("Payoffs"); + } + + const int player = m_table->GetColHeaderPlayer(row); + const int strat = m_table->GetColHeaderStrategy(row, col); + return wxString::FromUTF8(m_table->GetStrategyByPlayerAndIndex(player, strat)->GetLabel()); +} + +void ColPlayerTable::SetValue(int, int, const wxString &) +{ + // Column header cells are read-only: editing a player's strategies goes entirely through + // TableWidget::EditStrategies(), triggered on left-click rather than through wxGrid's own + // cell-editing machinery. This override exists only to satisfy the pure virtual + // wxGridTableBase interface. +} + +wxGridCellAttr *ColPlayerTable::GetAttr(int row, int col, wxGridCellAttr::wxAttrKind) +{ + auto *attr = new wxGridCellAttr(); + attr->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); + attr->SetAlignment(wxALIGN_CENTRE, wxALIGN_CENTRE); + // wxGridCellStringRenderer's default is to let text that's too wide spill + // into a neighbouring cell if that cell is otherwise empty -- wrong here, + // since the "neighbour" past a span boundary is a different strategy's + // header cell, not empty space. + attr->SetOverflow(false); + attr->SetReadOnly(true); + + if (m_table->GetColHeaderRowCount() > 0) { + attr->SetTextColour(m_table->GetPlayerColor(m_table->GetColHeaderPlayer(row))); + } + else { + attr->SetTextColour(*wxBLACK); + } + + // See the equivalent comment in RowPlayerTable::GetAttr: span has to be + // computed directly here, not via SetCellSize()/GetCellSize(), which + // would recurse back into this same override. Computed unconditionally + // (not just when there are real column players): with zero column + // players, GetColHeaderColSpan's formula naturally evaluates to the full + // width, merging the "Payoffs" placeholder header across every column. + const int span = m_table->GetColHeaderColSpan(row); + if (span > 1 && col % span == 0) { + attr->SetSize(1, span); + } + + attr->SetBackgroundColour(*wxLIGHT_GREY); + m_renderer->IncRef(); + attr->SetRenderer(m_renderer); + + return attr; +} + +void ColPlayerTable::OnUpdate() +{ + wxGrid *view = GetView(); + + const int newCols = m_table->GetColHeaderColCount(); + if (newCols > m_numCols) { + const int added = newCols - m_numCols; + m_numCols = newCols; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_APPENDED, added); + view->ProcessTableMessage(msg); + } + } + else if (newCols < m_numCols) { + const int removed = m_numCols - newCols; + const int pos = newCols; + m_numCols = newCols; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_DELETED, pos, removed); + view->ProcessTableMessage(msg); + } + } + + const int newRows = std::max(m_table->GetColHeaderRowCount(), 1); + if (newRows > m_numRows) { + const int added = newRows - m_numRows; + m_numRows = newRows; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_APPENDED, added); + view->ProcessTableMessage(msg); + } + } + else if (newRows < m_numRows) { + const int removed = m_numRows - newRows; + const int pos = newRows; + m_numRows = newRows; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_DELETED, pos, removed); + view->ProcessTableMessage(msg); + } + } + + // Spans are computed directly in GetAttr() (see the comment there), so + // there's no separate SetCellSize() pass needed here -- just repaint. + if (view) { + view->Refresh(); + } +} + +//========================================================================= +// class ColPlayerGrid +//========================================================================= + +class ColPlayerGrid final : public TableGridBase { + TableWidget *m_table; + ColPlayerTable *m_gridTable; + StrategyInfoPopup *m_infoPopup; + wxTimer m_hoverTimer; + int m_hoverRow{-1}, m_hoverCol{-1}; + + void OnCellLeftClick(wxGridEvent &) override; + void OnCellRightClick(wxGridEvent &); + void OnMotion(wxMouseEvent &); + void OnLeaveWindow(wxMouseEvent &); + void OnHoverTimer(wxTimerEvent &); + void DismissInfo(); + + void ShowPlacementMenu(int p_row, const wxPoint &p_pos); + +public: + explicit ColPlayerGrid(TableWidget *p_parent); + + void OnUpdate() { m_gridTable->OnUpdate(); } +}; + +ColPlayerGrid::ColPlayerGrid(TableWidget *p_parent) + : TableGridBase(p_parent, wxID_ANY), m_table(p_parent), + m_gridTable(new ColPlayerTable(p_parent)), m_infoPopup(new StrategyInfoPopup(this)) +{ + SetTable(m_gridTable, true); + SetGridLineColour(*wxBLACK); + wxWindow::SetBackgroundColour(*wxLIGHT_GREY); + EnableScrolling(false, false); + ShowScrollbars(wxSHOW_SB_NEVER, wxSHOW_SB_NEVER); + // See the equivalent comment in RowPlayerGrid's constructor. + Bind(wxEVT_MOUSEWHEEL, [](wxMouseEvent &) {}); + + Bind(wxEVT_GRID_CELL_RIGHT_CLICK, &ColPlayerGrid::OnCellRightClick, this); + // See the equivalent comment in RowPlayerGrid's constructor. + GetGridWindow()->Bind(wxEVT_MOTION, &ColPlayerGrid::OnMotion, this); + GetGridWindow()->Bind(wxEVT_LEAVE_WINDOW, &ColPlayerGrid::OnLeaveWindow, this); + m_hoverTimer.SetOwner(this); + Bind(wxEVT_TIMER, &ColPlayerGrid::OnHoverTimer, this); +} + +void ColPlayerGrid::OnCellLeftClick(wxGridEvent &p_event) +{ + DismissInfo(); + + if (m_table->GetColHeaderRowCount() == 0) { + TableGridBase::OnCellLeftClick(p_event); + return; + } + + if (m_table->IsReadOnly()) { + return; + } + + m_table->EditStrategies(m_table->GetColHeaderPlayer(p_event.GetRow())); +} + +void ColPlayerGrid::OnCellRightClick(wxGridEvent &p_event) +{ + DismissInfo(); + ShowPlacementMenu(p_event.GetRow(), p_event.GetPosition()); +} + +void ColPlayerGrid::OnMotion(wxMouseEvent &p_event) +{ + if (m_table->GetColHeaderRowCount() > 0) { + const wxGridCellCoords coords = XYToCell(p_event.GetPosition()); + if (coords.GetRow() != m_hoverRow || coords.GetCol() != m_hoverCol) { + DismissInfo(); + m_hoverRow = coords.GetRow(); + m_hoverCol = coords.GetCol(); + if (m_hoverRow >= 0 && m_hoverCol >= 0) { + m_hoverTimer.StartOnce(400); + } + } + } + p_event.Skip(); +} + +void ColPlayerGrid::OnLeaveWindow(wxMouseEvent &p_event) +{ + DismissInfo(); + p_event.Skip(); +} + +void ColPlayerGrid::DismissInfo() +{ + m_hoverTimer.Stop(); + m_hoverRow = m_hoverCol = -1; + if (m_infoPopup->IsShown()) { + m_infoPopup->Dismiss(); + } +} + +void ColPlayerGrid::OnHoverTimer(wxTimerEvent &) +{ + if (m_hoverRow < 0 || m_hoverCol < 0) { + return; + } + const int player = m_table->GetColHeaderPlayer(m_hoverRow); + const int strategy = m_table->GetColHeaderStrategy(m_hoverRow, m_hoverCol); + const auto gameStrategy = m_table->GetStrategyByPlayerAndIndex(player, strategy); + GameDocument *doc = m_table->GetDocument(); + if (!HasStrategyInfo(doc, gameStrategy)) { + return; + } + const wxRect cellRect = CellToRect(m_hoverRow, m_hoverCol); + const wxPoint anchor = GetGridWindow()->ClientToScreen(cellRect.GetBottomLeft()); + m_infoPopup->ShowForStrategy(doc, gameStrategy, anchor); +} + +// See the equivalent comment on RowPlayerGrid::ShowPlacementMenu. +void ColPlayerGrid::ShowPlacementMenu(int p_row, const wxPoint &p_pos) +{ + const bool hasColPlayers = m_table->GetColHeaderRowCount() > 0; + const int beforeIndex = hasColPlayers ? p_row + 1 : 1; + const int afterIndex = beforeIndex + 1; + + wxMenu menu; + wxMenu *beforeMenu = hasColPlayers ? new wxMenu : nullptr; + wxMenu *afterMenu = hasColPlayers ? new wxMenu : nullptr; + wxMenu *useMenu = hasColPlayers ? nullptr : new wxMenu; + std::map> placements; // menu id -> (index, player) + + for (const auto &player : m_table->GetDocument()->GetGame()->GetPlayers()) { + wxString label = wxString::FromUTF8(player->GetLabel()); + if (label.empty()) { + label = wxString::Format(_("Player %d"), player->GetNumber()); + } + const wxBitmap swatch = MakeColorSwatch(m_table->GetPlayerColor(player->GetNumber())); + + if (!hasColPlayers) { + const int id = wxWindow::NewControlId(); + auto *item = new wxMenuItem(useMenu, id, label); + item->SetBitmap(swatch); + useMenu->Append(item); + placements[id] = {1, player->GetNumber()}; + continue; + } + + if (!m_table->IsColPlayerPlacementNoOp(beforeIndex, player->GetNumber())) { + const int id = wxWindow::NewControlId(); + auto *item = new wxMenuItem(beforeMenu, id, label); + item->SetBitmap(swatch); + beforeMenu->Append(item); + placements[id] = {beforeIndex, player->GetNumber()}; + } + if (!m_table->IsColPlayerPlacementNoOp(afterIndex, player->GetNumber())) { + const int id = wxWindow::NewControlId(); + auto *item = new wxMenuItem(afterMenu, id, label); + item->SetBitmap(swatch); + afterMenu->Append(item); + placements[id] = {afterIndex, player->GetNumber()}; + } + } + + if (hasColPlayers) { + if (beforeMenu->GetMenuItemCount() > 0) { + menu.AppendSubMenu(beforeMenu, _("Place player before")); + } + else { + delete beforeMenu; + } + if (afterMenu->GetMenuItemCount() > 0) { + menu.AppendSubMenu(afterMenu, _("Place player after")); + } + else { + delete afterMenu; + } + } + else { + menu.AppendSubMenu(useMenu, _("Use as column player")); + } + + if (menu.GetMenuItemCount() == 0) { + return; + } + + const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); + const auto it = placements.find(selection); + if (it == placements.end()) { + return; + } + + try { + m_table->SetColPlayer(it->second.first, it->second.second); + } + catch (std::exception &ex) { + ExceptionDialog(this, ex.what()).ShowModal(); + } +} + +//========================================================================= +// TableWidget: row/col header grid construction/update +//========================================================================= + +void TableWidget::InitHeaderGrids() +{ + m_rowGrid = new RowPlayerGrid(this); + m_colGrid = new ColPlayerGrid(this); +} + +void TableWidget::UpdateRowGrid() { dynamic_cast(m_rowGrid)->OnUpdate(); } + +void TableWidget::UpdateColGrid() { dynamic_cast(m_colGrid)->OnUpdate(); } +} // namespace Gambit::GUI diff --git a/src/gui/nfgpanel.cc b/src/gui/nfgpanel.cc index 776ac4851a..0446c56a1a 100644 --- a/src/gui/nfgpanel.cc +++ b/src/gui/nfgpanel.cc @@ -23,9 +23,7 @@ #include #ifndef WX_PRECOMP #include -#endif // WX_PRECOMP -#include // for drag-and-drop features -#include // for picking player colors +#endif // WX_PRECOMP #include "gamedoc.h" #include "nfgpanel.h" @@ -33,266 +31,8 @@ #include "dlexcept.h" #include "nfgtable.h" #include "menuconst.h" -#include "edittext.h" - -#include "bitmaps/color.xpm" -#include "bitmaps/person.xpm" namespace Gambit::GUI { -class TablePlayerIcon final : public wxStaticBitmap { - int m_player; - - // Event handlers - void OnLeftClick(wxMouseEvent &); - -public: - TablePlayerIcon(wxWindow *p_parent, int p_player); - - DECLARE_EVENT_TABLE() -}; - -BEGIN_EVENT_TABLE(TablePlayerIcon, wxStaticBitmap) -EVT_LEFT_DOWN(TablePlayerIcon::OnLeftClick) -END_EVENT_TABLE() - -TablePlayerIcon::TablePlayerIcon(wxWindow *p_parent, int p_player) - : wxStaticBitmap(p_parent, wxID_ANY, wxBitmap(person_xpm)), m_player(p_player) -{ -} - -void TablePlayerIcon::OnLeftClick(wxMouseEvent &) -{ - wxString label; - label << "P" << m_player; - wxTextDataObject textData(label); - wxDropSource source(textData, this); - source.DoDragDrop(wxDrag_DefaultMove); -} - -class TablePlayerPanel final : public wxPanel { - GameDocument *m_doc; - int m_player; - EditableLabelText *m_playerLabel; - wxStaticText *m_payoff; - - /// @name Event handlers - //@{ - /// The set color icon is clicked - void OnSetColor(wxCommandEvent &); - /// Start the editing of the player label - void OnEditPlayerLabel(wxCommandEvent &); - /// End the editing of the player label after enter is pressed - void OnAcceptPlayerLabel(wxCommandEvent &); - /// Process a keypress event - void OnChar(wxKeyEvent &); - //@} - -public: - TablePlayerPanel(wxWindow *, NfgPanel *, GameDocument *, int p_player); - - void OnUpdate(); - void PostPendingChanges(); - - DECLARE_EVENT_TABLE() -}; - -BEGIN_EVENT_TABLE(TablePlayerPanel, wxPanel) -EVT_CHAR(TablePlayerPanel::OnChar) -END_EVENT_TABLE() - -TablePlayerPanel::TablePlayerPanel(wxWindow *p_parent, NfgPanel *p_nfgPanel, GameDocument *p_doc, - int p_player) - : wxPanel(p_parent, wxID_ANY), - /* m_nfgPanel(p_nfgPanel),*/ m_doc(p_doc), m_player(p_player) -{ - auto *topSizer = new wxBoxSizer(wxVERTICAL); - - auto *labelSizer = new wxBoxSizer(wxHORIZONTAL); - - wxStaticBitmap *playerIcon = new TablePlayerIcon(this, m_player); - labelSizer->Add(playerIcon, 0, wxALL | wxALIGN_CENTER, 0); - - auto *setColorIcon = new wxBitmapButton(this, wxID_ANY, wxBitmap(color_xpm), wxDefaultPosition, - wxDefaultSize, wxNO_BORDER); - setColorIcon->SetToolTip(_("Change the color for this player")); - - labelSizer->Add(setColorIcon, 0, wxALL | wxALIGN_CENTER, 0); - Connect(setColorIcon->GetId(), wxEVT_COMMAND_BUTTON_CLICKED, - wxCommandEventHandler(TablePlayerPanel::OnSetColor)); - - 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, - wxCommandEventHandler(TablePlayerPanel::OnEditPlayerLabel)); - Connect(m_playerLabel->GetId(), wxEVT_COMMAND_TEXT_ENTER, - wxCommandEventHandler(TablePlayerPanel::OnAcceptPlayerLabel)); - - topSizer->Add(labelSizer, 0, wxALL, 0); - - m_payoff = new wxStaticText(this, wxID_STATIC, wxT("Payoff:"), wxDefaultPosition, wxDefaultSize, - wxALIGN_CENTER | wxST_NO_AUTORESIZE); - m_payoff->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL)); - topSizer->Add(m_payoff, 0, wxALL | wxEXPAND, 0); - topSizer->Show(m_payoff, false); - - SetSizer(topSizer); - topSizer->SetSizeHints(this); - topSizer->Fit(this); - wxWindowBase::Layout(); - OnUpdate(); -} - -void TablePlayerPanel::OnUpdate() -{ - const wxColour color = m_doc->GetStyle().GetPlayerColor(m_doc->GetGame()->GetPlayer(m_player)); - - m_playerLabel->SetForegroundColour(color); - m_playerLabel->SetValue(wxString::FromUTF8(m_doc->GetGame()->GetPlayer(m_player)->GetLabel())); - - if (m_doc->GetWorkspace().GetCurrentProfile() > 0) { - m_payoff->SetForegroundColour(color); - - const std::string pay = m_doc->GetWorkspace().GetProfiles().GetPayoff(m_player); - m_payoff->SetLabel(wxT("Payoff: ") + wxString::FromUTF8(pay)); - GetSizer()->Show(m_payoff, true); - } - else { - GetSizer()->Show(m_payoff, false); - } - - GetSizer()->Layout(); - GetSizer()->SetSizeHints(this); - GetSizer()->Fit(this); -} - -void TablePlayerPanel::OnChar(wxKeyEvent &p_event) -{ - if (p_event.GetKeyCode() == WXK_ESCAPE) { - m_playerLabel->EndEdit(false); - } - else { - p_event.Skip(); - } -} - -void TablePlayerPanel::OnSetColor(wxCommandEvent &) -{ - wxColourData data; - data.SetColour(m_doc->GetStyle().GetPlayerColor(m_doc->GetGame()->GetPlayer(m_player))); - wxColourDialog dialog(this, &data); - wxString label; - label << _("Choose color for player ") << m_player; - dialog.SetTitle(label); - - if (dialog.ShowModal() == wxID_OK) { - const wxColour color = dialog.GetColourData().GetColour(); - TreeRenderConfig style = m_doc->GetStyle(); - style.SetPlayerColor(m_player, color); - m_doc->SetStyle(style); - } -} - -void TablePlayerPanel::OnEditPlayerLabel(wxCommandEvent &) -{ - m_doc->PostPendingChanges(); - m_playerLabel->BeginEdit(); -} - -void TablePlayerPanel::OnAcceptPlayerLabel(wxCommandEvent &) -{ - const wxString label = LabelTextCtrl::Normalize(m_playerLabel->GetValue(), true); - - if (label.empty()) { - wxBell(); - m_playerLabel->BeginEdit(); - return; - } - - 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() -{ - if (!m_playerLabel->IsEditing()) { - return; - } - m_playerLabel->EndEdit(true); - try { - m_doc->DoSetPlayerLabel(m_doc->GetGame()->GetPlayer(m_player), m_playerLabel->GetValue()); - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - m_playerLabel->SetValue(wxString::FromUTF8(m_doc->GetGame()->GetPlayer(m_player)->GetLabel())); - } -} - -//===================================================================== -// class TablePlayerToolbar -//===================================================================== - -class TablePlayerToolbar final : public wxPanel, public GameView { - NfgPanel *m_nfgPanel; - Array m_playerPanels; - -public: - TablePlayerToolbar(NfgPanel *p_parent, GameDocument *p_doc); - - /// @name Implementation of GameView members - //@{ - void OnUpdate() override; - void PostPendingChanges() override; - //@} -}; - -TablePlayerToolbar::TablePlayerToolbar(NfgPanel *p_parent, GameDocument *p_doc) - : wxPanel(p_parent, wxID_ANY, wxDefaultPosition, wxSize(210, -1)), GameView(p_doc), - m_nfgPanel(p_parent) -{ - auto *topSizer = new wxBoxSizer(wxVERTICAL); - - for (size_t pl = 1; pl <= m_doc->GetGame()->NumPlayers(); pl++) { - m_playerPanels.push_back(new TablePlayerPanel(this, p_parent, m_doc, pl)); - topSizer->Add(m_playerPanels[pl], 0, wxALL | wxEXPAND, 5); - } - - SetSizer(topSizer); - wxWindowBase::Layout(); -} - -void TablePlayerToolbar::OnUpdate() -{ - while (m_playerPanels.size() < m_doc->GetGame()->NumPlayers()) { - auto *panel = new TablePlayerPanel(this, m_nfgPanel, 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()) { - TablePlayerPanel *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(&TablePlayerPanel::OnUpdate)); - GetSizer()->Layout(); -} - -void TablePlayerToolbar::PostPendingChanges() -{ - std::for_each(m_playerPanels.begin(), m_playerPanels.end(), - std::mem_fn(&TablePlayerPanel::PostPendingChanges)); -} - //===================================================================== // class StrategyDominanceToolbar //===================================================================== @@ -423,17 +163,12 @@ END_EVENT_TABLE() NfgPanel::NfgPanel(wxWindow *p_parent, GameDocument *p_doc, bool p_showDominance) : wxPanel(p_parent, wxID_ANY), GameView(p_doc), m_dominanceToolbar(new StrategyDominanceToolbar(this, m_doc)), - m_playerToolbar(new TablePlayerToolbar(this, m_doc)), m_tableWidget(new TableWidget(this, wxID_ANY, m_doc)) { - auto *playerSizer = new wxBoxSizer(wxHORIZONTAL); - playerSizer->Add(m_playerToolbar, 0, wxEXPAND, 0); - playerSizer->Add(m_tableWidget, 1, wxEXPAND, 0); - auto *topSizer = new wxBoxSizer(wxVERTICAL); topSizer->Add(m_dominanceToolbar, 0, wxEXPAND, 0); topSizer->Show(m_dominanceToolbar, p_showDominance); - topSizer->Add(playerSizer, 1, wxEXPAND, 0); + topSizer->Add(m_tableWidget, 1, wxEXPAND, 0); SetSizer(topSizer); wxWindowBase::Layout(); OnUpdate(); @@ -456,7 +191,6 @@ void NfgPanel::OnToolsDominance(wxCommandEvent &p_event) void NfgPanel::OnUpdate() { - m_playerToolbar->OnUpdate(); m_tableWidget->OnUpdate(); GetSizer()->Layout(); } diff --git a/src/gui/nfgpanel.h b/src/gui/nfgpanel.h index 78bbdec5b5..d97209989c 100644 --- a/src/gui/nfgpanel.h +++ b/src/gui/nfgpanel.h @@ -27,11 +27,9 @@ class wxPrintout; namespace Gambit::GUI { class TableWidget; -class TablePlayerToolbar; class NfgPanel final : public wxPanel, public GameView { wxWindow *m_dominanceToolbar; - TablePlayerToolbar *m_playerToolbar; TableWidget *m_tableWidget; void OnToolsDominance(wxCommandEvent &); diff --git a/src/gui/nfgpayoffgrid.cc b/src/gui/nfgpayoffgrid.cc new file mode 100644 index 0000000000..55c2d6fb03 --- /dev/null +++ b/src/gui/nfgpayoffgrid.cc @@ -0,0 +1,330 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/gui/nfgpayoffgrid.cc +// The payoff-matrix grid for the strategic game matrix display +// +// 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 "renratio.h" // special renderer/editor for rational numbers + +#include "gamedoc.h" +#include "nfgtable.h" + +namespace Gambit::GUI { + +//========================================================================= +// class PayoffCellRenderer +//========================================================================= + +//! +//! Draws payoff cells: rational-number rendering (from RationalCellRenderer), +//! regular-weight black lines marking contingency/row boundaries (with the +//! grid's own light-gray default gridlines left to separate the columns +//! within a single contingency), and the dominance-indicator overlay. +//! +class PayoffCellRenderer final : public RationalCellRenderer { + TableWidget *m_table; + + //! + //! Draws a 1px black line inset within this cell's own rect (not at the + //! exact shared pixel with the next cell/row) so it survives painting + //! regardless of draw order between adjacent cells: the neighbouring + //! cell's own background fill covers only its own rect and never reaches + //! back into pixels this cell already owns, whereas a line drawn exactly + //! at the shared boundary pixel sits in the neighbour's own territory and + //! gets erased by its fill. + //! + static void DrawInsetLine(wxDC &dc, int x1, int y1, int x2, int y2) + { + dc.SetPen(wxPen(*wxBLACK, 1, wxPENSTYLE_SOLID)); + dc.DrawLine(x1, y1, x2, y2); + } + +public: + explicit PayoffCellRenderer(TableWidget *p_table) : m_table(p_table) {} + + void Draw(wxGrid &grid, wxGridCellAttr &attr, wxDC &dc, const wxRect &rect, int row, int col, + bool isSelected) override + { + RationalCellRenderer::Draw(grid, attr, dc, rect, row, col, isSelected); + + // Vertical: black at contingency boundaries (grid's own light-gray + // default gridlines already separate the columns within a contingency). + const int perContingency = m_table->GetPayoffColumnsPerContingency(); + if ((col + 1) % perContingency == 0) { + const int x = rect.x + rect.width - 1; + DrawInsetLine(dc, x, rect.y, x, rect.y + rect.height); + } + + // Horizontal: every row is its own contingency, so every row boundary + // is black -- bottom of every cell, plus the top edge for row 0 (no + // preceding row's bottom line to serve as its top). + DrawInsetLine(dc, rect.x, rect.y + rect.height - 1, rect.x + rect.width, + rect.y + rect.height - 1); + if (row == 0) { + DrawInsetLine(dc, rect.x, rect.y, rect.x + rect.width, rect.y); + } + + if (!m_table->ShowDominance()) { + return; + } + + auto player = m_table->GetPayoffPlayer(col); + + if (m_table->IsPayoffStrategyDominated(row, col, false)) { + if (m_table->IsPayoffStrategyDominated(row, col, true)) { + dc.SetPen(wxPen(m_table->GetPlayerColor(player->GetNumber()), 2, wxPENSTYLE_SOLID)); + } + else { + dc.SetPen(wxPen(m_table->GetPlayerColor(player->GetNumber()), 1, wxPENSTYLE_SHORT_DASH)); + } + dc.DrawLine(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height); + dc.DrawLine(rect.x + rect.width, rect.y, rect.x, rect.y + rect.height); + } + } + + wxGridCellRenderer *Clone() const override { return new PayoffCellRenderer(m_table); } +}; + +//========================================================================= +// class PayoffTable +//========================================================================= + +class PayoffTable final : public wxGridTableBase { + TableWidget *m_table; + int m_numRows{0}; + int m_numCols{0}; + PayoffCellRenderer *m_renderer; + RationalCellEditor *m_editor; + +public: + explicit PayoffTable(TableWidget *p_table) + : m_table(p_table), m_renderer(new PayoffCellRenderer(p_table)), + m_editor(new RationalCellEditor()) + { + m_renderer->IncRef(); + m_editor->IncRef(); + } + + ~PayoffTable() override + { + m_renderer->DecRef(); + m_editor->DecRef(); + } + + int GetNumberRows() override { return m_numRows; } + int GetNumberCols() override { return m_numCols; } + bool IsEmptyCell(int, int) override { return false; } + wxString GetValue(int row, int col) override; + void SetValue(int row, int col, const wxString &value) override; + wxGridCellAttr *GetAttr(int row, int col, wxGridCellAttr::wxAttrKind kind) override; + + void OnUpdate(); +}; + +wxString PayoffTable::GetValue(int row, int col) +{ + const PureStrategyProfile profile = m_table->GetPayoffProfile(row, col); + const auto player = m_table->GetPayoffPlayer(col); + try { + if (const auto outcome = profile->GetOutcome()) { + return wxString::FromUTF8(outcome->GetPayoff(player)); + } + return wxString::FromUTF8("0"); + } + catch (const UndefinedException &) { + return wxString::FromUTF8(lexical_cast(profile->GetPayoff(player))); + } +} + +void PayoffTable::SetValue(int row, int col, const wxString &value) +{ + wxString trimmed = value; + if (trimmed.EndsWith(_T("/"))) { + trimmed = trimmed.Left(trimmed.length() - 1); + } + m_table->SetPayoffCellValue(row, col, trimmed); +} + +wxGridCellAttr *PayoffTable::GetAttr(int row, int col, wxGridCellAttr::wxAttrKind) +{ + auto *attr = new wxGridCellAttr(); + attr->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); + attr->SetAlignment(wxALIGN_CENTRE, wxALIGN_CENTRE); + // wxGridCellStringRenderer's default is to let text that's too wide spill + // into a neighbouring cell if that cell is otherwise empty -- wrong here, + // since the "neighbour" past a span boundary is a different strategy's + // header cell, not empty space. + attr->SetOverflow(false); + attr->SetTextColour(m_table->GetPlayerColor(m_table->GetPayoffPlayerForColumn(col))); + attr->SetBackgroundColour(*wxWHITE); + m_renderer->IncRef(); + attr->SetRenderer(m_renderer); + m_editor->IncRef(); + attr->SetEditor(m_editor); + attr->SetReadOnly(m_table->IsReadOnly()); + return attr; +} + +void PayoffTable::OnUpdate() +{ + wxGrid *view = GetView(); + + const int newCols = m_table->GetPayoffColCount(); + if (newCols > m_numCols) { + const int added = newCols - m_numCols; + m_numCols = newCols; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_APPENDED, added); + view->ProcessTableMessage(msg); + } + } + else if (newCols < m_numCols) { + const int removed = m_numCols - newCols; + const int pos = newCols; + m_numCols = newCols; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_DELETED, pos, removed); + view->ProcessTableMessage(msg); + } + } + + const int newRows = m_table->GetPayoffRowCount(); + if (newRows > m_numRows) { + const int added = newRows - m_numRows; + m_numRows = newRows; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_APPENDED, added); + view->ProcessTableMessage(msg); + } + } + else if (newRows < m_numRows) { + const int removed = m_numRows - newRows; + const int pos = newRows; + m_numRows = newRows; + if (view) { + wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_DELETED, pos, removed); + view->ProcessTableMessage(msg); + } + } + + if (view) { + view->Refresh(); + } +} + +//========================================================================= +// class PayoffGrid +//========================================================================= + +class PayoffGrid final : public TableGridBase { + TableWidget *m_table; + PayoffTable *m_gridTable; + + /// @name Overriding wxGrid behavior for event-handling + //@{ + /// Implement custom tab-traversal behavior + void OnKeyDown(wxKeyEvent &); + void OnCharHook(wxKeyEvent &); + void HandleTabTraversal(wxKeyEvent &); + void MoveEditorByTab(bool p_backwards); + //@} + +public: + explicit PayoffGrid(TableWidget *p_parent); + + void OnUpdate() { m_gridTable->OnUpdate(); } +}; + +PayoffGrid::PayoffGrid(TableWidget *p_parent) + : TableGridBase(p_parent, wxID_ANY), m_table(p_parent), m_gridTable(new PayoffTable(p_parent)) +{ + SetTable(m_gridTable, true); + + Bind(wxEVT_KEY_DOWN, &PayoffGrid::OnKeyDown, this); + Bind(wxEVT_CHAR_HOOK, &PayoffGrid::OnCharHook, this); +} + +void PayoffGrid::MoveEditorByTab(bool p_backwards) +{ + if (!GetNumberRows() || !GetNumberCols() || !IsCellEditControlShown()) { + return; + } + + DisableCellEditControl(); + + int newRow = GetGridCursorRow(); + int newCol = GetGridCursorCol(); + + if (p_backwards) { + --newCol; + if (newCol < 0) { + newCol = GetNumberCols() - 1; + --newRow; + if (newRow < 0) { + newRow = GetNumberRows() - 1; + } + } + } + else { + ++newCol; + if (newCol >= GetNumberCols()) { + newCol = 0; + ++newRow; + if (newRow >= GetNumberRows()) { + newRow = 0; + } + } + } + + SetGridCursor(newRow, newCol); + MakeCellVisible(newRow, newCol); + + // Deferred: creating the new cell's editor in the same call stack that's + // still tearing down the old (focused) one is unreliable. + CallAfter([this] { EnableCellEditControl(); }); +} + +void PayoffGrid::HandleTabTraversal(wxKeyEvent &p_event) +{ + if (p_event.GetKeyCode() != WXK_TAB || !IsCellEditControlShown()) { + p_event.Skip(); + return; + } + MoveEditorByTab(p_event.ShiftDown()); +} + +void PayoffGrid::OnKeyDown(wxKeyEvent &p_event) { HandleTabTraversal(p_event); } + +void PayoffGrid::OnCharHook(wxKeyEvent &p_event) { HandleTabTraversal(p_event); } + +//========================================================================= +// TableWidget: payoff grid construction/update +//========================================================================= + +void TableWidget::InitPayoffGrid() { m_payoffGrid = new PayoffGrid(this); } + +void TableWidget::UpdatePayoffGrid() { dynamic_cast(m_payoffGrid)->OnUpdate(); } +} // namespace Gambit::GUI diff --git a/src/gui/nfgtable.cc b/src/gui/nfgtable.cc index e7039f4fd7..6406a6ac99 100644 --- a/src/gui/nfgtable.cc +++ b/src/gui/nfgtable.cc @@ -24,16 +24,11 @@ #ifndef WX_PRECOMP #include #endif // WX_PRECOMP -#include // for drag-and-drop support #include // for printing support #include // for SVG output -#include #include -#include "renratio.h" // special renderer/editor for rational numbers -#include "editlabel.h" - #include "gamedoc.h" #include "nfgpanel.h" #include "nfgtable.h" @@ -42,74 +37,6 @@ namespace Gambit::GUI { -//========================================================================= -// class TableGridBase -//========================================================================= - -//! -//! This class handles some common overriding of wxGrid behavior -//! common to the grids used in the strategic game display. -//! -class TableGridBase : public wxGrid { - //! - //! @name Suppressing the built-in selection highlight & cursor rectangle - //! - //@{ - void OnRangeSelecting(wxGridRangeSelectEvent &p_event) { p_event.Veto(); } - void OnSelectCell(wxGridEvent &p_event) { p_event.Skip(); } - //@} - -protected: - /// Shows the editor on one click; overridden by the header grids to show - /// a strategy description popup instead when the grid is read-only. - virtual void OnCellLeftClick(wxGridEvent &p_event) - { - SetGridCursor(p_event.GetRow(), p_event.GetCol()); - EnableCellEditControl(); - p_event.Skip(false); - } - -public: - explicit TableGridBase(wxWindow *p_parent, wxWindowID p_id = wxID_ANY) : wxGrid(p_parent, p_id) - { - SetCellHighlightPenWidth(0); - SetCellHighlightROPenWidth(0); - SetRowLabelSize(0); - SetColLabelSize(0); - - Bind(wxEVT_GRID_RANGE_SELECTING, &TableGridBase::OnRangeSelecting, this); - Bind(wxEVT_GRID_SELECT_CELL, &TableGridBase::OnSelectCell, this); - Bind(wxEVT_GRID_CELL_LEFT_CLICK, &TableGridBase::OnCellLeftClick, this); - } - - /// @name Drop target interaction - //@{ - /// Called when the drop target receives text. - virtual bool DropText(wxCoord, wxCoord, const wxString &) { return false; } - //@} -}; - -//========================================================================= -// class TableWidgetDropTarget -//========================================================================= - -//! -//! This simple class serves as a drop target for players; it simply -//! communicates the location and text of the drop to its owner for -//! further processing -//! -class TableWidgetDropTarget : public wxTextDropTarget { - TableGridBase *m_owner; - -public: - explicit TableWidgetDropTarget(TableGridBase *p_owner) : m_owner(p_owner) {} - - bool OnDropText(wxCoord x, wxCoord y, const wxString &p_text) override - { - return m_owner->DropText(x, y, p_text); - } -}; - //========================================================================= // helper: draw a whole grid to an arbitrary DC //========================================================================= @@ -139,980 +66,6 @@ static void DrawGridToDC(wxGrid *p_grid, wxDC &p_dc) } } -//========================================================================= -// class RowHeaderCellRenderer -//========================================================================= - -//! -//! Draws the row-player header cells: default string rendering, plus a -//! diagonal dominance-indicator overlay when applicable. -//! -class RowHeaderCellRenderer final : public wxGridCellStringRenderer { - TableWidget *m_table; - -public: - explicit RowHeaderCellRenderer(TableWidget *p_table) : m_table(p_table) {} - - void Draw(wxGrid &grid, wxGridCellAttr &attr, wxDC &dc, const wxRect &rect, int row, int col, - bool isSelected) override - { - wxGridCellStringRenderer::Draw(grid, attr, dc, rect, row, col, isSelected); - - if (!m_table->ShowDominance() || m_table->GetRowHeaderColCount() == 0) { - return; - } - - if (m_table->IsRowHeaderStrategyDominated(col, row, false)) { - const int player = m_table->GetRowHeaderPlayer(col); - if (m_table->IsRowHeaderStrategyDominated(col, row, true)) { - dc.SetPen(wxPen(m_table->GetPlayerColor(player), 2, wxPENSTYLE_SOLID)); - } - else { - dc.SetPen(wxPen(m_table->GetPlayerColor(player), 1, wxPENSTYLE_SHORT_DASH)); - } - dc.DrawLine(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height); - dc.DrawLine(rect.x + rect.width, rect.y, rect.x, rect.y + rect.height); - } - } - - wxGridCellRenderer *Clone() const override { return new RowHeaderCellRenderer(m_table); } -}; - -//========================================================================= -// class RowPlayerTable -//========================================================================= - -class RowPlayerTable final : public wxGridTableBase { - TableWidget *m_table; - int m_numRows{0}; - int m_numCols{0}; - RowHeaderCellRenderer *m_renderer; - -public: - explicit RowPlayerTable(TableWidget *p_table) - : m_table(p_table), m_renderer(new RowHeaderCellRenderer(p_table)) - { - // Freshly-constructed wxGridCellWorker-derived objects start unreferenced; - // this is our own table's stake, matched by DecRef() in the destructor. - // Each SetRenderer() call below additionally consumes one reference per - // call (that's the ownership contract of that setter), so GetAttr() takes - // a fresh IncRef() immediately before each one -- the instance itself is - // reused across all cells and all calls, which is required: wxGrid - // expects the *same* renderer object identity back on repeated queries - // for a given cell. - m_renderer->IncRef(); - } - - ~RowPlayerTable() override { m_renderer->DecRef(); } - - int GetNumberRows() override { return m_numRows; } - int GetNumberCols() override { return m_numCols; } - bool IsEmptyCell(int, int) override { return false; } - wxString GetValue(int row, int col) override; - void SetValue(int row, int col, const wxString &value) override; - wxGridCellAttr *GetAttr(int row, int col, wxGridCellAttr::wxAttrKind kind) override; - - /// Reconciles row/col count and span pattern with the current document state. - void OnUpdate(); -}; - -wxString RowPlayerTable::GetValue(int row, int col) -{ - if (m_table->GetRowHeaderColCount() == 0) { - return wxT("Payoffs"); - } - - const int player = m_table->GetRowHeaderPlayer(col); - const int strat = m_table->GetRowHeaderStrategy(col, row); - return wxString::FromUTF8(m_table->GetStrategyByPlayerAndIndex(player, strat)->GetLabel()); -} - -void RowPlayerTable::SetValue(int, int, const wxString &) -{ - // Row header cells are read-only: editing a player's strategies goes entirely through - // TableWidget::EditStrategies(), triggered on left-click rather than through wxGrid's own - // cell-editing machinery. This override exists only to satisfy the pure virtual - // wxGridTableBase interface. -} - -wxGridCellAttr *RowPlayerTable::GetAttr(int row, int col, wxGridCellAttr::wxAttrKind) -{ - auto *attr = new wxGridCellAttr(); - attr->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); - attr->SetAlignment(wxALIGN_CENTRE, wxALIGN_CENTRE); - // wxGridCellStringRenderer's default is to let text that's too wide spill - // into a neighbouring cell if that cell is otherwise empty -- wrong here, - // since the "neighbour" past a span boundary is a different strategy's - // header cell, not empty space. - attr->SetOverflow(false); - attr->SetReadOnly(true); - - if (m_table->GetRowHeaderColCount() > 0) { - attr->SetTextColour(m_table->GetPlayerColor(m_table->GetRowHeaderPlayer(col))); - } - else { - attr->SetTextColour(*wxBLACK); - } - - // GetAttr() constructs a fresh attr on every call, so the span has to be - // computed and set directly here rather than via wxGrid::SetCellSize() - // -- that call (and GetCellSize()) themselves go through GetAttr(), which - // would recurse infinitely if this override tried to consult it. Only - // the anchor (first row of each span group) gets a non-trivial size; - // interior rows are left at the default 1x1. Computed unconditionally - // (matching ColPlayerTable::GetAttr) rather than gated on - // GetRowHeaderColCount() > 0, for the same reason: don't rely on a - // formula's edge-case behaviour matching whichever branch happens to - // call it. - const int span = m_table->GetRowHeaderRowSpan(col); - if (span > 1 && row % span == 0) { - attr->SetSize(span, 1); - } - - attr->SetBackgroundColour(*wxLIGHT_GREY); - m_renderer->IncRef(); - attr->SetRenderer(m_renderer); - - return attr; -} - -void RowPlayerTable::OnUpdate() -{ - wxGrid *view = GetView(); - - const int newRows = m_table->GetRowHeaderRowCount(); - if (newRows > m_numRows) { - const int added = newRows - m_numRows; - m_numRows = newRows; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_APPENDED, added); - view->ProcessTableMessage(msg); - } - } - else if (newRows < m_numRows) { - const int removed = m_numRows - newRows; - const int pos = newRows; - m_numRows = newRows; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_DELETED, pos, removed); - view->ProcessTableMessage(msg); - } - } - - const int newCols = std::max(m_table->GetRowHeaderColCount(), 1); - if (newCols > m_numCols) { - const int added = newCols - m_numCols; - m_numCols = newCols; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_APPENDED, added); - view->ProcessTableMessage(msg); - } - } - else if (newCols < m_numCols) { - const int removed = m_numCols - newCols; - const int pos = newCols; - m_numCols = newCols; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_DELETED, pos, removed); - view->ProcessTableMessage(msg); - } - } - - // Spans are computed directly in GetAttr() (see the comment there), so - // there's no separate SetCellSize() pass needed here -- just repaint. - if (view) { - view->Refresh(); - } -} - -//========================================================================= -// class RowPlayerGrid -//========================================================================= - -class RowPlayerGrid final : public TableGridBase { - TableWidget *m_table; - RowPlayerTable *m_gridTable; - - void OnCellLeftClick(wxGridEvent &) override; - - bool ShowPlayerDropMenu(int p_index, int p_player, const wxString &p_label, - const wxPoint &p_pos); - -public: - explicit RowPlayerGrid(TableWidget *p_parent); - - void OnUpdate() { m_gridTable->OnUpdate(); } - - bool DropText(wxCoord p_x, wxCoord p_y, const wxString &p_text) override; -}; - -RowPlayerGrid::RowPlayerGrid(TableWidget *p_parent) - : TableGridBase(p_parent, wxID_ANY), m_table(p_parent), m_gridTable(new RowPlayerTable(p_parent)) -{ - SetTable(m_gridTable, true); - SetGridLineColour(*wxBLACK); - EnableScrolling(false, false); - ShowScrollbars(wxSHOW_SB_NEVER, wxSHOW_SB_NEVER); - // EnableScrolling(false, false) doesn't stop wxGrid's own mouse-wheel - // handling, which bypasses the base wxScrolledWindow gate -- this pane - // must only ever move in response to the payoff grid's scroll sync. - Bind(wxEVT_MOUSEWHEEL, [](wxMouseEvent &) {}); - - // wxGrid is a composite widget -- the actual window that receives mouse - // and OS drag events over the cell area is the internal grid window, not - // the outer wxGrid object, so the drop target has to be registered there. - GetGridWindow()->SetDropTarget(new TableWidgetDropTarget(this)); -} - -namespace { -wxString GetStrategyTooltip(const GameStrategy &p_strategy) -{ - if (!p_strategy->GetGame()->IsTree()) { - return {}; - } - - wxString tooltip; - for (const auto &infoset : p_strategy->GetPlayer()->GetInfosets()) { - const auto action = p_strategy->GetAction(infoset); - if (!action) { - continue; - } - - if (!tooltip.empty()) { - tooltip << wxT("\n"); - } - tooltip << wxString::Format(_("Information set %d"), infoset->GetNumber()); - if (!infoset->GetLabel().empty()) { - tooltip << wxT(" (") << wxString(infoset->GetLabel().c_str(), *wxConvCurrent) << wxT(")"); - } - tooltip << wxT(": "); - if (action->GetLabel().empty()) { - tooltip << wxString::Format(_("action %d"), action->GetNumber()); - } - else { - tooltip << wxString(action->GetLabel().c_str(), *wxConvCurrent); - } - } - return tooltip; -} - -class StrategyDescriptionPopup final : public wxPopupTransientWindow { -public: - StrategyDescriptionPopup(wxWindow *p_parent, const GameStrategy &p_strategy) - : wxPopupTransientWindow(p_parent, wxBORDER_NONE) - { - SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); - - auto *content = new wxPanel(this); - content->SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); - - auto *contentSizer = new wxBoxSizer(wxVERTICAL); - const wxString label(p_strategy->GetLabel().c_str(), *wxConvCurrent); - auto *heading = new wxStaticText(content, wxID_ANY, wxString::Format(_("Strategy %s"), label)); - wxFont headingFont = heading->GetFont(); - headingFont.SetWeight(wxFONTWEIGHT_BOLD); - headingFont.SetPointSize(headingFont.GetPointSize() + 1); - heading->SetFont(headingFont); - contentSizer->Add(heading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); - - auto *description = new wxStaticText(content, wxID_ANY, GetStrategyTooltip(p_strategy)); - description->Wrap(FromDIP(420)); - contentSizer->Add(description, 0, wxALL, FromDIP(12)); - content->SetSizer(contentSizer); - - auto *popupSizer = new wxBoxSizer(wxVERTICAL); - popupSizer->Add(content, 1, wxEXPAND | wxALL, FromDIP(1)); - SetSizerAndFit(popupSizer); - } - -protected: - void OnDismiss() override { Destroy(); } -}; - -void ShowStrategyPopup(wxGrid *p_grid, int p_row, int p_col, const GameStrategy &p_strategy) -{ - auto *popup = new StrategyDescriptionPopup(p_grid, p_strategy); - const wxRect cellRect = p_grid->CellToRect(p_row, p_col); - const wxPoint anchor = p_grid->GetGridWindow()->ClientToScreen(cellRect.GetBottomLeft()); - popup->Position(anchor, wxSize(popup->FromDIP(8), popup->FromDIP(8))); - popup->Popup(); -} - -} // namespace - -void RowPlayerGrid::OnCellLeftClick(wxGridEvent &p_event) -{ - if (m_table->GetRowHeaderColCount() == 0) { - TableGridBase::OnCellLeftClick(p_event); - return; - } - - const int col = p_event.GetCol(); - const int player = m_table->GetRowHeaderPlayer(col); - - if (m_table->IsReadOnly()) { - const int row = p_event.GetRow(); - const int strategy = m_table->GetRowHeaderStrategy(col, row); - const auto gameStrategy = m_table->GetStrategyByPlayerAndIndex(player, strategy); - if (!GetStrategyTooltip(gameStrategy).empty()) { - ShowStrategyPopup(this, row, col, gameStrategy); - } - return; - } - - m_table->EditStrategies(player); -} - -bool RowPlayerGrid::ShowPlayerDropMenu(int p_index, int p_player, const wxString &p_label, - const wxPoint &p_pos) -{ - if (m_table->IsRowPlayerPlacementNoOp(p_index, p_player)) { - return true; - } - - const int placePlayerId = wxWindow::NewControlId(); - - wxMenu menu; - menu.Append(placePlayerId, p_label); - - const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); - if (selection != placePlayerId) { - return false; - } - - try { - m_table->SetRowPlayer(p_index, p_player); - return true; - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } - - return false; -} - -bool RowPlayerGrid::DropText(wxCoord p_x, wxCoord p_y, const wxString &p_text) -{ - if (p_text.empty() || p_text[0] != 'P') { - return false; - } - - long player; - if (!p_text.Right(p_text.Length() - 1).ToLong(&player)) { - return false; - } - - if (m_table->NumRowPlayers() == 0) { - return ShowPlayerDropMenu(1, static_cast(player), _("Use as row player"), - wxPoint(p_x, p_y)); - } - - for (int col = 0; col < GetNumberCols(); col++) { - const wxRect rect = CellToRect(0, col); - const int existingPlayer = m_table->GetRowHeaderPlayer(col); - const wxString playerLabel = wxString::Format(_("Player %d"), existingPlayer); - - if (p_x >= rect.x && p_x < rect.x + rect.width / 2) { - return ShowPlayerDropMenu(col + 1, static_cast(player), - wxString::Format(_("Place before %s"), playerLabel), - wxPoint(p_x, p_y)); - } - - if (p_x >= rect.x + rect.width / 2 && p_x < rect.x + rect.width) { - return ShowPlayerDropMenu(col + 2, static_cast(player), - wxString::Format(_("Place after %s"), playerLabel), - wxPoint(p_x, p_y)); - } - } - - return false; -} - -//========================================================================= -// class ColHeaderCellRenderer -//========================================================================= - -//! -//! Draws the column-player header cells: default string rendering, plus a -//! diagonal dominance-indicator overlay when applicable. -//! -class ColHeaderCellRenderer final : public wxGridCellStringRenderer { - TableWidget *m_table; - -public: - explicit ColHeaderCellRenderer(TableWidget *p_table) : m_table(p_table) {} - - void Draw(wxGrid &grid, wxGridCellAttr &attr, wxDC &dc, const wxRect &rect, int row, int col, - bool isSelected) override - { - wxGridCellStringRenderer::Draw(grid, attr, dc, rect, row, col, isSelected); - - if (!m_table->ShowDominance() || m_table->GetColHeaderRowCount() == 0) { - return; - } - - if (m_table->IsColHeaderStrategyDominated(row, col, false)) { - const int player = m_table->GetColHeaderPlayer(row); - if (m_table->IsColHeaderStrategyDominated(row, col, true)) { - dc.SetPen(wxPen(m_table->GetPlayerColor(player), 2, wxPENSTYLE_SOLID)); - } - else { - dc.SetPen(wxPen(m_table->GetPlayerColor(player), 1, wxPENSTYLE_SHORT_DASH)); - } - dc.DrawLine(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height); - dc.DrawLine(rect.x + rect.width, rect.y, rect.x, rect.y + rect.height); - } - } - - wxGridCellRenderer *Clone() const override { return new ColHeaderCellRenderer(m_table); } -}; - -//========================================================================= -// class ColPlayerTable -//========================================================================= - -class ColPlayerTable final : public wxGridTableBase { - TableWidget *m_table; - int m_numRows{0}; - int m_numCols{0}; - ColHeaderCellRenderer *m_renderer; - -public: - explicit ColPlayerTable(TableWidget *p_table) - : m_table(p_table), m_renderer(new ColHeaderCellRenderer(p_table)) - { - m_renderer->IncRef(); - } - - ~ColPlayerTable() override { m_renderer->DecRef(); } - - int GetNumberRows() override { return m_numRows; } - int GetNumberCols() override { return m_numCols; } - bool IsEmptyCell(int, int) override { return false; } - wxString GetValue(int row, int col) override; - void SetValue(int row, int col, const wxString &value) override; - wxGridCellAttr *GetAttr(int row, int col, wxGridCellAttr::wxAttrKind kind) override; - - void OnUpdate(); -}; - -wxString ColPlayerTable::GetValue(int row, int col) -{ - if (m_table->GetColHeaderRowCount() == 0) { - return wxT("Payoffs"); - } - - const int player = m_table->GetColHeaderPlayer(row); - const int strat = m_table->GetColHeaderStrategy(row, col); - return wxString::FromUTF8(m_table->GetStrategyByPlayerAndIndex(player, strat)->GetLabel()); -} - -void ColPlayerTable::SetValue(int, int, const wxString &) -{ - // Column header cells are read-only: editing a player's strategies goes entirely through - // TableWidget::EditStrategies(), triggered on left-click rather than through wxGrid's own - // cell-editing machinery. This override exists only to satisfy the pure virtual - // wxGridTableBase interface. -} - -wxGridCellAttr *ColPlayerTable::GetAttr(int row, int col, wxGridCellAttr::wxAttrKind) -{ - auto *attr = new wxGridCellAttr(); - attr->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); - attr->SetAlignment(wxALIGN_CENTRE, wxALIGN_CENTRE); - // wxGridCellStringRenderer's default is to let text that's too wide spill - // into a neighbouring cell if that cell is otherwise empty -- wrong here, - // since the "neighbour" past a span boundary is a different strategy's - // header cell, not empty space. - attr->SetOverflow(false); - attr->SetReadOnly(true); - - if (m_table->GetColHeaderRowCount() > 0) { - attr->SetTextColour(m_table->GetPlayerColor(m_table->GetColHeaderPlayer(row))); - } - else { - attr->SetTextColour(*wxBLACK); - } - - // See the equivalent comment in RowPlayerTable::GetAttr: span has to be - // computed directly here, not via SetCellSize()/GetCellSize(), which - // would recurse back into this same override. Computed unconditionally - // (not just when there are real column players): with zero column - // players, GetColHeaderColSpan's formula naturally evaluates to the full - // width, merging the "Payoffs" placeholder header across every column. - const int span = m_table->GetColHeaderColSpan(row); - if (span > 1 && col % span == 0) { - attr->SetSize(1, span); - } - - attr->SetBackgroundColour(*wxLIGHT_GREY); - m_renderer->IncRef(); - attr->SetRenderer(m_renderer); - - return attr; -} - -void ColPlayerTable::OnUpdate() -{ - wxGrid *view = GetView(); - - const int newCols = m_table->GetColHeaderColCount(); - if (newCols > m_numCols) { - const int added = newCols - m_numCols; - m_numCols = newCols; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_APPENDED, added); - view->ProcessTableMessage(msg); - } - } - else if (newCols < m_numCols) { - const int removed = m_numCols - newCols; - const int pos = newCols; - m_numCols = newCols; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_DELETED, pos, removed); - view->ProcessTableMessage(msg); - } - } - - const int newRows = std::max(m_table->GetColHeaderRowCount(), 1); - if (newRows > m_numRows) { - const int added = newRows - m_numRows; - m_numRows = newRows; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_APPENDED, added); - view->ProcessTableMessage(msg); - } - } - else if (newRows < m_numRows) { - const int removed = m_numRows - newRows; - const int pos = newRows; - m_numRows = newRows; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_DELETED, pos, removed); - view->ProcessTableMessage(msg); - } - } - - // Spans are computed directly in GetAttr() (see the comment there), so - // there's no separate SetCellSize() pass needed here -- just repaint. - if (view) { - view->Refresh(); - } -} - -//========================================================================= -// class ColPlayerGrid -//========================================================================= - -class ColPlayerGrid final : public TableGridBase { - TableWidget *m_table; - ColPlayerTable *m_gridTable; - - void OnCellLeftClick(wxGridEvent &) override; - - bool ShowPlayerDropMenu(int p_index, int p_player, const wxString &p_label, - const wxPoint &p_pos); - -public: - explicit ColPlayerGrid(TableWidget *p_parent); - - void OnUpdate() { m_gridTable->OnUpdate(); } - - bool DropText(wxCoord p_x, wxCoord p_y, const wxString &p_text) override; -}; - -ColPlayerGrid::ColPlayerGrid(TableWidget *p_parent) - : TableGridBase(p_parent, wxID_ANY), m_table(p_parent), m_gridTable(new ColPlayerTable(p_parent)) -{ - SetTable(m_gridTable, true); - SetGridLineColour(*wxBLACK); - wxWindow::SetBackgroundColour(*wxLIGHT_GREY); - EnableScrolling(false, false); - ShowScrollbars(wxSHOW_SB_NEVER, wxSHOW_SB_NEVER); - // See the equivalent comment in RowPlayerGrid's constructor. - Bind(wxEVT_MOUSEWHEEL, [](wxMouseEvent &) {}); - - // wxGrid is a composite widget -- the actual window that receives mouse - // and OS drag events over the cell area is the internal grid window, not - // the outer wxGrid object, so the drop target has to be registered there. - GetGridWindow()->SetDropTarget(new TableWidgetDropTarget(this)); -} - -void ColPlayerGrid::OnCellLeftClick(wxGridEvent &p_event) -{ - if (m_table->GetColHeaderRowCount() == 0) { - TableGridBase::OnCellLeftClick(p_event); - return; - } - - const int row = p_event.GetRow(); - const int player = m_table->GetColHeaderPlayer(row); - - if (m_table->IsReadOnly()) { - const int col = p_event.GetCol(); - const int strategy = m_table->GetColHeaderStrategy(row, col); - const auto gameStrategy = m_table->GetStrategyByPlayerAndIndex(player, strategy); - if (!GetStrategyTooltip(gameStrategy).empty()) { - ShowStrategyPopup(this, row, col, gameStrategy); - } - return; - } - - m_table->EditStrategies(player); -} - -bool ColPlayerGrid::ShowPlayerDropMenu(int p_index, int p_player, const wxString &p_label, - const wxPoint &p_pos) -{ - if (m_table->IsColPlayerPlacementNoOp(p_index, p_player)) { - return true; - } - - const int placePlayerId = wxWindow::NewControlId(); - - wxMenu menu; - menu.Append(placePlayerId, p_label); - - const int selection = GetPopupMenuSelectionFromUser(menu, p_pos); - if (selection != placePlayerId) { - return false; - } - - try { - m_table->SetColPlayer(p_index, p_player); - return true; - } - catch (std::exception &ex) { - ExceptionDialog(this, ex.what()).ShowModal(); - } - - return false; -} - -bool ColPlayerGrid::DropText(wxCoord p_x, wxCoord p_y, const wxString &p_text) -{ - if (p_text.empty() || p_text[0] != 'P') { - return false; - } - - long player; - if (!p_text.Right(p_text.Length() - 1).ToLong(&player)) { - return false; - } - - if (m_table->NumColPlayers() == 0) { - return ShowPlayerDropMenu(1, static_cast(player), _("Use as column player"), - wxPoint(p_x, p_y)); - } - - for (int row = 0; row < GetNumberRows(); row++) { - const wxRect rect = CellToRect(row, 0); - const int existingPlayer = m_table->GetColHeaderPlayer(row); - const wxString playerLabel = wxString::Format(_("Player %d"), existingPlayer); - - if (p_y >= rect.y && p_y < rect.y + rect.height / 2) { - return ShowPlayerDropMenu(row + 1, static_cast(player), - wxString::Format(_("Place before %s"), playerLabel), - wxPoint(p_x, p_y)); - } - - if (p_y >= rect.y + rect.height / 2 && p_y < rect.y + rect.height) { - return ShowPlayerDropMenu(row + 2, static_cast(player), - wxString::Format(_("Place after %s"), playerLabel), - wxPoint(p_x, p_y)); - } - } - - return false; -} - -//========================================================================= -// class PayoffCellRenderer -//========================================================================= - -//! -//! Draws payoff cells: rational-number rendering (from RationalCellRenderer), -//! regular-weight black lines marking contingency/row boundaries (with the -//! grid's own light-gray default gridlines left to separate the columns -//! within a single contingency), and the dominance-indicator overlay. -//! -class PayoffCellRenderer final : public RationalCellRenderer { - TableWidget *m_table; - - //! - //! Draws a 1px black line inset within this cell's own rect (not at the - //! exact shared pixel with the next cell/row) so it survives painting - //! regardless of draw order between adjacent cells: the neighbouring - //! cell's own background fill covers only its own rect and never reaches - //! back into pixels this cell already owns, whereas a line drawn exactly - //! at the shared boundary pixel sits in the neighbour's own territory and - //! gets erased by its fill. - //! - static void DrawInsetLine(wxDC &dc, int x1, int y1, int x2, int y2) - { - dc.SetPen(wxPen(*wxBLACK, 1, wxPENSTYLE_SOLID)); - dc.DrawLine(x1, y1, x2, y2); - } - -public: - explicit PayoffCellRenderer(TableWidget *p_table) : m_table(p_table) {} - - void Draw(wxGrid &grid, wxGridCellAttr &attr, wxDC &dc, const wxRect &rect, int row, int col, - bool isSelected) override - { - RationalCellRenderer::Draw(grid, attr, dc, rect, row, col, isSelected); - - // Vertical: black at contingency boundaries (grid's own light-gray - // default gridlines already separate the columns within a contingency). - const int perContingency = m_table->GetPayoffColumnsPerContingency(); - if ((col + 1) % perContingency == 0) { - const int x = rect.x + rect.width - 1; - DrawInsetLine(dc, x, rect.y, x, rect.y + rect.height); - } - - // Horizontal: every row is its own contingency, so every row boundary - // is black -- bottom of every cell, plus the top edge for row 0 (no - // preceding row's bottom line to serve as its top). - DrawInsetLine(dc, rect.x, rect.y + rect.height - 1, rect.x + rect.width, - rect.y + rect.height - 1); - if (row == 0) { - DrawInsetLine(dc, rect.x, rect.y, rect.x + rect.width, rect.y); - } - - if (!m_table->ShowDominance()) { - return; - } - - auto player = m_table->GetPayoffPlayer(col); - - if (m_table->IsPayoffStrategyDominated(row, col, false)) { - if (m_table->IsPayoffStrategyDominated(row, col, true)) { - dc.SetPen(wxPen(m_table->GetPlayerColor(player->GetNumber()), 2, wxPENSTYLE_SOLID)); - } - else { - dc.SetPen(wxPen(m_table->GetPlayerColor(player->GetNumber()), 1, wxPENSTYLE_SHORT_DASH)); - } - dc.DrawLine(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height); - dc.DrawLine(rect.x + rect.width, rect.y, rect.x, rect.y + rect.height); - } - } - - wxGridCellRenderer *Clone() const override { return new PayoffCellRenderer(m_table); } -}; - -//========================================================================= -// class PayoffTable -//========================================================================= - -class PayoffTable final : public wxGridTableBase { - TableWidget *m_table; - int m_numRows{0}; - int m_numCols{0}; - PayoffCellRenderer *m_renderer; - RationalCellEditor *m_editor; - -public: - explicit PayoffTable(TableWidget *p_table) - : m_table(p_table), m_renderer(new PayoffCellRenderer(p_table)), - m_editor(new RationalCellEditor()) - { - m_renderer->IncRef(); - m_editor->IncRef(); - } - - ~PayoffTable() override - { - m_renderer->DecRef(); - m_editor->DecRef(); - } - - int GetNumberRows() override { return m_numRows; } - int GetNumberCols() override { return m_numCols; } - bool IsEmptyCell(int, int) override { return false; } - wxString GetValue(int row, int col) override; - void SetValue(int row, int col, const wxString &value) override; - wxGridCellAttr *GetAttr(int row, int col, wxGridCellAttr::wxAttrKind kind) override; - - void OnUpdate(); -}; - -wxString PayoffTable::GetValue(int row, int col) -{ - const PureStrategyProfile profile = m_table->GetPayoffProfile(row, col); - const auto player = m_table->GetPayoffPlayer(col); - try { - if (const auto outcome = profile->GetOutcome()) { - return wxString::FromUTF8(outcome->GetPayoff(player)); - } - return wxString::FromUTF8("0"); - } - catch (const UndefinedException &) { - return wxString::FromUTF8(lexical_cast(profile->GetPayoff(player))); - } -} - -void PayoffTable::SetValue(int row, int col, const wxString &value) -{ - wxString trimmed = value; - if (trimmed.EndsWith(_T("/"))) { - trimmed = trimmed.Left(trimmed.length() - 1); - } - m_table->SetPayoffCellValue(row, col, trimmed); -} - -wxGridCellAttr *PayoffTable::GetAttr(int row, int col, wxGridCellAttr::wxAttrKind) -{ - auto *attr = new wxGridCellAttr(); - attr->SetFont(wxFont(10, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); - attr->SetAlignment(wxALIGN_CENTRE, wxALIGN_CENTRE); - // wxGridCellStringRenderer's default is to let text that's too wide spill - // into a neighbouring cell if that cell is otherwise empty -- wrong here, - // since the "neighbour" past a span boundary is a different strategy's - // header cell, not empty space. - attr->SetOverflow(false); - attr->SetTextColour(m_table->GetPlayerColor(m_table->GetPayoffPlayerForColumn(col))); - attr->SetBackgroundColour(*wxWHITE); - m_renderer->IncRef(); - attr->SetRenderer(m_renderer); - m_editor->IncRef(); - attr->SetEditor(m_editor); - attr->SetReadOnly(m_table->IsReadOnly()); - return attr; -} - -void PayoffTable::OnUpdate() -{ - wxGrid *view = GetView(); - - const int newCols = m_table->GetPayoffColCount(); - if (newCols > m_numCols) { - const int added = newCols - m_numCols; - m_numCols = newCols; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_APPENDED, added); - view->ProcessTableMessage(msg); - } - } - else if (newCols < m_numCols) { - const int removed = m_numCols - newCols; - const int pos = newCols; - m_numCols = newCols; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_COLS_DELETED, pos, removed); - view->ProcessTableMessage(msg); - } - } - - const int newRows = m_table->GetPayoffRowCount(); - if (newRows > m_numRows) { - const int added = newRows - m_numRows; - m_numRows = newRows; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_APPENDED, added); - view->ProcessTableMessage(msg); - } - } - else if (newRows < m_numRows) { - const int removed = m_numRows - newRows; - const int pos = newRows; - m_numRows = newRows; - if (view) { - wxGridTableMessage msg(this, wxGRIDTABLE_NOTIFY_ROWS_DELETED, pos, removed); - view->ProcessTableMessage(msg); - } - } - - if (view) { - view->Refresh(); - } -} - -//========================================================================= -// class PayoffGrid -//========================================================================= - -class PayoffGrid final : public TableGridBase { - TableWidget *m_table; - PayoffTable *m_gridTable; - - /// @name Overriding wxGrid behavior for event-handling - //@{ - /// Implement custom tab-traversal behavior - void OnKeyDown(wxKeyEvent &); - void OnCharHook(wxKeyEvent &); - void HandleTabTraversal(wxKeyEvent &); - void MoveEditorByTab(bool p_backwards); - //@} - -public: - explicit PayoffGrid(TableWidget *p_parent); - - void OnUpdate() { m_gridTable->OnUpdate(); } -}; - -PayoffGrid::PayoffGrid(TableWidget *p_parent) - : TableGridBase(p_parent, wxID_ANY), m_table(p_parent), m_gridTable(new PayoffTable(p_parent)) -{ - SetTable(m_gridTable, true); - - Bind(wxEVT_KEY_DOWN, &PayoffGrid::OnKeyDown, this); - Bind(wxEVT_CHAR_HOOK, &PayoffGrid::OnCharHook, this); -} - -void PayoffGrid::MoveEditorByTab(bool p_backwards) -{ - if (!GetNumberRows() || !GetNumberCols() || !IsCellEditControlShown()) { - return; - } - - DisableCellEditControl(); - - int newRow = GetGridCursorRow(); - int newCol = GetGridCursorCol(); - - if (p_backwards) { - --newCol; - if (newCol < 0) { - newCol = GetNumberCols() - 1; - --newRow; - if (newRow < 0) { - newRow = GetNumberRows() - 1; - } - } - } - else { - ++newCol; - if (newCol >= GetNumberCols()) { - newCol = 0; - ++newRow; - if (newRow >= GetNumberRows()) { - newRow = 0; - } - } - } - - SetGridCursor(newRow, newCol); - MakeCellVisible(newRow, newCol); - - // Deferred: creating the new cell's editor in the same call stack that's - // still tearing down the old (focused) one is unreliable. - CallAfter([this] { EnableCellEditControl(); }); -} - -void PayoffGrid::HandleTabTraversal(wxKeyEvent &p_event) -{ - if (p_event.GetKeyCode() != WXK_TAB || !IsCellEditControlShown()) { - p_event.Skip(); - return; - } - MoveEditorByTab(p_event.ShiftDown()); -} - -void PayoffGrid::OnKeyDown(wxKeyEvent &p_event) { HandleTabTraversal(p_event); } - -void PayoffGrid::OnCharHook(wxKeyEvent &p_event) { HandleTabTraversal(p_event); } - //========================================================================= // TableWidget: Lifecycle //========================================================================= @@ -1147,11 +100,8 @@ TableWidget::TableWidget(NfgPanel *p_parent, wxWindowID p_id, GameDocument *p_do // These depend on the row and column player lists having been populated, // which suggests some refactoring ought to be done as to where/how those // row and column players are recorded - // NOLINTBEGIN(cppcoreguidelines-prefer-member-initializer) - m_payoffGrid = new PayoffGrid(this); - m_rowGrid = new RowPlayerGrid(this); - m_colGrid = new ColPlayerGrid(this); - // NOLINTEND(cppcoreguidelines-prefer-member-initializer) + InitPayoffGrid(); + InitHeaderGrids(); // Near-invisible default gridlines for separators within a contingency // (columns sharing the same strategy profile, one per player) -- the // player colour-coding on the text already carries that grouping, so the @@ -1336,7 +286,7 @@ void TableWidget::OnBeginEdit(wxGridEvent &) { m_doc->PostPendingChanges(); } void TableWidget::ReconcilePlayers() { m_layout->ReconcilePlayers(); } -void TableWidget::UpdatePayoffPanel() { dynamic_cast(m_payoffGrid)->OnUpdate(); } +void TableWidget::UpdatePayoffPanel() { UpdatePayoffGrid(); } void TableWidget::UpdateLabelPanelMargins() { @@ -1352,8 +302,8 @@ void TableWidget::UpdateLabelPanelMargins() void TableWidget::UpdateLabelPanels() { - dynamic_cast(m_rowGrid)->OnUpdate(); - dynamic_cast(m_colGrid)->OnUpdate(); + UpdateRowGrid(); + UpdateColGrid(); } void TableWidget::OnUpdate() @@ -1595,5 +545,4 @@ GameStrategy TableWidget::GetStrategyByPlayerAndIndex(int player, int strategy) auto strategies = GetSupport().GetStrategies(GetSupport().GetGame()->GetPlayer(player)); return *std::next(strategies.begin(), strategy - 1); } - } // namespace Gambit::GUI diff --git a/src/gui/nfgtable.h b/src/gui/nfgtable.h index 2688ebc13f..11a9a954bf 100644 --- a/src/gui/nfgtable.h +++ b/src/gui/nfgtable.h @@ -27,10 +27,54 @@ #include "games/stratpure.h" +// Forward-declared rather than requiring : TableWidget::GetPrintout() only +// ever needs the pointer type here, and not every .cc file including this header (e.g. +// nfgheadergrid.cc) has a reason to pull in the printing headers itself. +class wxPrintout; + namespace Gambit::GUI { class GameDocument; class NfgPanel; +//! +//! This class handles some common overriding of wxGrid behavior common to the grids used +//! in the strategic game display (nfgtable.cc's TableGridBase, nfgheadergrid.cc's +//! RowPlayerGrid/ColPlayerGrid, nfgpayoffgrid.cc's PayoffGrid) -- shared here, rather than +//! file-local to one of them, since it's a base class used across all three .cc files. +//! +class TableGridBase : public wxGrid { + //! + //! @name Suppressing the built-in selection highlight & cursor rectangle + //! + //@{ + void OnRangeSelecting(wxGridRangeSelectEvent &p_event) { p_event.Veto(); } + void OnSelectCell(wxGridEvent &p_event) { p_event.Skip(); } + //@} + +protected: + /// Shows the editor on one click; overridden by the header grids to edit strategies + /// instead (or do nothing, if the strategic form is read-only). + virtual void OnCellLeftClick(wxGridEvent &p_event) + { + SetGridCursor(p_event.GetRow(), p_event.GetCol()); + EnableCellEditControl(); + p_event.Skip(false); + } + +public: + explicit TableGridBase(wxWindow *p_parent, wxWindowID p_id = wxID_ANY) : wxGrid(p_parent, p_id) + { + SetCellHighlightPenWidth(0); + SetCellHighlightROPenWidth(0); + SetRowLabelSize(0); + SetColLabelSize(0); + + Bind(wxEVT_GRID_RANGE_SELECTING, &TableGridBase::OnRangeSelecting, this); + Bind(wxEVT_GRID_SELECT_CELL, &TableGridBase::OnSelectCell, this); + Bind(wxEVT_GRID_CELL_LEFT_CLICK, &TableGridBase::OnCellLeftClick, this); + } +}; + class StrategicTableLayout { GameDocument *m_doc; std::vector m_rowPlayers; @@ -275,6 +319,22 @@ class TableWidget final : public wxPanel { void UpdateLabelPanels(); //@} + /// @name Constructing/updating the row/col/payoff grids + //@{ + /// Constructs m_payoffGrid (defined in nfgpayoffgrid.cc, alongside PayoffGrid) and + /// m_rowGrid/m_colGrid (defined in nfgheadergrid.cc, alongside RowPlayerGrid/ + /// ColPlayerGrid) -- called from the constructor body rather than its initializer + /// list, since those grid classes' complete types aren't visible here. + void InitPayoffGrid(); + void InitHeaderGrids(); + /// These call down to PayoffGrid::OnUpdate()/RowPlayerGrid::OnUpdate()/ + /// ColPlayerGrid::OnUpdate() (via a dynamic_cast back to the concrete type), for the + /// same reason InitPayoffGrid()/InitHeaderGrids() above exist. + void UpdatePayoffGrid(); + void UpdateRowGrid(); + void UpdateColGrid(); + //@} + int GetRowPaneWidth() const; int GetColPaneHeight() const; void UpdateLabelPanelSizes(); @@ -394,6 +454,7 @@ class TableWidget final : public wxPanel { bool IsReadOnly() const; wxColour GetPlayerColor(int player) const; const StrategySupportProfile &GetSupport() const { return m_doc->GetNfgSupport(); } + GameDocument *GetDocument() const { return m_doc; } bool IsRowHeaderStrategyDominated(int headerCol, int headerRow, bool strict) const; bool IsColHeaderStrategyDominated(int headerRow, int headerCol, bool strict) const; diff --git a/src/gui/style.cc b/src/gui/style.cc index 867b165071..29ac8f9991 100644 --- a/src/gui/style.cc +++ b/src/gui/style.cc @@ -322,4 +322,15 @@ void TreeRenderConfig::Load(const LegacyWorkspaceFile &p_workspace) m_numDecimals = *p_workspace.decimals; } } + +wxBitmap MakeColorSwatch(const wxColour &p_color, int p_size) +{ + wxBitmap bitmap(p_size, p_size); + wxMemoryDC dc(bitmap); + dc.SetBackground(wxBrush(p_color)); + dc.Clear(); + dc.SelectObject(wxNullBitmap); + return bitmap; +} + } // namespace Gambit::GUI diff --git a/src/gui/style.h b/src/gui/style.h index 2a0261756a..68560a6e5f 100644 --- a/src/gui/style.h +++ b/src/gui/style.h @@ -204,6 +204,10 @@ class TreeRenderConfig { void Load(const LegacyWorkspaceFile &p_workspace); }; +/// Builds a small solid-color square bitmap, e.g. for use as a player-color swatch +/// on a menu item or button. +wxBitmap MakeColorSwatch(const wxColour &p_color, int p_size = 16); + } // namespace Gambit::GUI #endif // STYLE_H