diff --git a/ChangeLog b/ChangeLog index 3d0aad983..836c13e68 100644 --- a/ChangeLog +++ b/ChangeLog @@ -5,6 +5,7 @@ ### Added - Implement `GameSubgameRep` (C++) and `Subgame` (Python), a first-class object representing a subgame. (#585) - Games can be materialised directly from OpenSpiel games if `pyspiel` is installed. (#917) +- Magnify events are now supported in GUI for zooming in/out on trees. ### Fixed - Corrected resizing of row and column index labels in strategic form so pivoting works correctly. (#844) diff --git a/src/gui/efgdisplay.cc b/src/gui/efgdisplay.cc index 27d498834..b62c27cb9 100644 --- a/src/gui/efgdisplay.cc +++ b/src/gui/efgdisplay.cc @@ -313,6 +313,7 @@ BEGIN_EVENT_TABLE(EfgDisplay, wxScrolledWindow) EVT_MOTION(EfgDisplay::OnMouseMotion) 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) @@ -612,16 +613,34 @@ void EfgDisplay::RefreshTree() Refresh(); } +constexpr int kScrollPixelsPerUnit = 1; + void EfgDisplay::AdjustScrollbarSteps() { - int width, height; - GetClientSize(&width, &height); + int oldPixelsPerUnitX, oldPixelsPerUnitY; + GetScrollPixelsPerUnit(&oldPixelsPerUnitX, &oldPixelsPerUnitY); int scrollX, scrollY; GetViewStart(&scrollX, &scrollY); - SetScrollbars(50, 50, LayoutToDevice(m_layout.MaxX()) / 50 + 1, - LayoutToDevice(m_layout.MaxY()) / 50 + 1, scrollX, scrollY); + const int currentPixelX = scrollX * oldPixelsPerUnitX; + const int currentPixelY = scrollY * oldPixelsPerUnitY; + + int clientWidth, clientHeight; + GetClientSize(&clientWidth, &clientHeight); + + const int virtualWidth = LayoutToDevice(m_layout.MaxX()); + const int virtualHeight = LayoutToDevice(m_layout.MaxY()); + + const int maxPixelX = std::max(0, virtualWidth - clientWidth); + const int maxPixelY = std::max(0, virtualHeight - clientHeight); + + const int clampedPixelX = std::clamp(currentPixelX, 0, maxPixelX); + const int clampedPixelY = std::clamp(currentPixelY, 0, maxPixelY); + + SetScrollbars(kScrollPixelsPerUnit, kScrollPixelsPerUnit, + virtualWidth / kScrollPixelsPerUnit + 1, virtualHeight / kScrollPixelsPerUnit + 1, + clampedPixelX / kScrollPixelsPerUnit, clampedPixelY / kScrollPixelsPerUnit); } void EfgDisplay::FitZoom() @@ -640,14 +659,85 @@ void EfgDisplay::FitZoom() Refresh(); } -void EfgDisplay::SetZoom(int p_zoom) +namespace { + +constexpr int kMinZoom = 10; +constexpr int kMaxZoom = 150; +constexpr int kZoomStep = 10; +constexpr int kScrollPixelsPerUnit = 1; + +int ClampZoom(int p_zoom) { return std::clamp(p_zoom, kMinZoom, kMaxZoom); } + +} // namespace + +void EfgDisplay::SetZoom(int p_zoom, bool p_keepSelectionVisible) { - m_zoom = p_zoom; + const int zoom = ClampZoom(p_zoom); + if (zoom == m_zoom) { + return; + } + + m_zoom = zoom; AdjustScrollbarSteps(); - EnsureNodeVisible(m_doc->GetSelectNode()); + + if (p_keepSelectionVisible) { + EnsureNodeVisible(m_doc->GetSelectNode()); + } + Refresh(); } +void EfgDisplay::ZoomByFactor(double p_factor, const wxPoint &p_clientPoint) +{ + if (p_factor <= 0.0) { + return; + } + + const int oldZoom = GetZoom(); + const int newZoom = ClampZoom(static_cast(std::lround(oldZoom * p_factor))); + + if (newZoom == oldZoom) { + return; + } + + int unscrolledX, unscrolledY; + CalcUnscrolledPosition(p_clientPoint.x, p_clientPoint.y, &unscrolledX, &unscrolledY); + + const double oldScale = GetZoom() / 100.0; + const double layoutX = unscrolledX / oldScale; + const double layoutY = unscrolledY / oldScale; + + SetZoom(newZoom, false); + + const double newScale = GetZoom() / 100.0; + const int targetUnscrolledX = static_cast(std::lround(layoutX * newScale)); + const int targetUnscrolledY = static_cast(std::lround(layoutY * newScale)); + + int pixelsPerUnitX, pixelsPerUnitY; + GetScrollPixelsPerUnit(&pixelsPerUnitX, &pixelsPerUnitY); + + if (pixelsPerUnitX <= 0 || pixelsPerUnitY <= 0) { + return; + } + + const int targetScrollX = targetUnscrolledX - p_clientPoint.x; + const int targetScrollY = targetUnscrolledY - p_clientPoint.y; + + int clientWidth, clientHeight; + GetClientSize(&clientWidth, &clientHeight); + + int virtualWidth, virtualHeight; + GetVirtualSize(&virtualWidth, &virtualHeight); + + const int maxPixelX = std::max(0, virtualWidth - clientWidth); + const int maxPixelY = std::max(0, virtualHeight - clientHeight); + + const int clampedScrollX = std::clamp(targetScrollX, 0, maxPixelX); + const int clampedScrollY = std::clamp(targetScrollY, 0, maxPixelY); + + Scroll(clampedScrollX / pixelsPerUnitX, clampedScrollY / pixelsPerUnitY); +} + void EfgDisplay::OnDraw(wxDC &p_dc) { p_dc.SetUserScale(GetScale(), GetScale()); @@ -873,6 +963,13 @@ void EfgDisplay::OnLeftDoubleClick(wxMouseEvent &p_event) } } +void EfgDisplay::OnMagnify(wxMouseEvent &p_event) +{ + if (const double factor = 1.0 + p_event.GetMagnification(); factor > 0.0) { + ZoomByFactor(factor, p_event.GetPosition()); + } +} + #include "bitmaps/tree.xpm" #include "bitmaps/move.xpm" diff --git a/src/gui/efgdisplay.h b/src/gui/efgdisplay.h index f9042c4e0..3f10e8d69 100644 --- a/src/gui/efgdisplay.h +++ b/src/gui/efgdisplay.h @@ -59,7 +59,6 @@ class EfgDisplay final : public wxScrolledWindow, public GameView { TreePayoffEditor *m_payoffEditor; bool m_pendingInitialZoom{true}; - // Private Functions void MakeMenus(); void AdjustScrollbarSteps(); @@ -70,6 +69,7 @@ class EfgDisplay final : public wxScrolledWindow, public GameView { void OnLeftClick(wxMouseEvent &); void OnRightClick(wxMouseEvent &); void OnLeftDoubleClick(wxMouseEvent &); + void OnMagnify(wxMouseEvent &); void OnKeyEvent(wxKeyEvent &); /// Payoff editor changes accepted with enter void OnAcceptPayoffEdit(wxCommandEvent &); @@ -86,6 +86,8 @@ class EfgDisplay final : public wxScrolledWindow, public GameView { /// @brief Scroll the viewport such that the node is at the specified fraction of the viewport void FocusNode(const GameNode &p_node, double p_xFrac = 0.5, double p_yFrac = 0.5); + void ZoomByFactor(double p_factor, const wxPoint &p_clientPoint); + public: EfgDisplay(wxWindow *p_parent, GameDocument *p_doc); @@ -93,7 +95,7 @@ class EfgDisplay final : public wxScrolledWindow, public GameView { void OnDraw(wxDC &, double); int GetZoom() const { return m_zoom; } - void SetZoom(int p_zoom); + void SetZoom(int p_zoom, bool p_keepSelectionVisible = true); void FitZoom(); double GetScale() const { return 0.01 * m_zoom; } diff --git a/src/gui/efgpanel.cc b/src/gui/efgpanel.cc index c9e1631e6..4767fb47b 100644 --- a/src/gui/efgpanel.cc +++ b/src/gui/efgpanel.cc @@ -20,6 +20,8 @@ // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. // +#include + #include #ifndef WX_PRECOMP #include @@ -500,22 +502,24 @@ EfgPanel::EfgPanel(wxWindow *p_parent, GameDocument *p_doc) wxWindowBase::Layout(); } +namespace { + +constexpr int kMinZoom = 10; +constexpr int kMaxZoom = 150; +constexpr int kZoomStep = 10; + +int ClampZoom(int p_zoom) { return std::clamp(p_zoom, kMinZoom, kMaxZoom); } + +} // namespace + void EfgPanel::OnViewZoomIn(wxCommandEvent &) { - int zoom = m_treeWindow->GetZoom(); - if (zoom < 150) { - zoom += 10; - } - m_treeWindow->SetZoom(zoom); + m_treeWindow->SetZoom(ClampZoom(m_treeWindow->GetZoom() + kZoomStep)); } void EfgPanel::OnViewZoomOut(wxCommandEvent &) { - int zoom = m_treeWindow->GetZoom(); - if (zoom > 10) { - zoom -= 10; - } - m_treeWindow->SetZoom(zoom); + m_treeWindow->SetZoom(ClampZoom(m_treeWindow->GetZoom() - kZoomStep)); } void EfgPanel::OnViewZoom100(wxCommandEvent &) { m_treeWindow->SetZoom(100); } @@ -605,8 +609,6 @@ void EfgPanel::RenderGame(wxDC &p_dc, int p_marginX, int p_marginY) auto posY = ((h - (maxY * scale)) / 2.0); p_dc.SetDeviceOrigin(static_cast(posX), static_cast(posY)); - printf("Drawing with scale %f\n", scale); - // Draw! m_treeWindow->OnDraw(p_dc, scale); } diff --git a/src/gui/gameframe.cc b/src/gui/gameframe.cc index dbd4d61da..260030245 100644 --- a/src/gui/gameframe.cc +++ b/src/gui/gameframe.cc @@ -249,7 +249,7 @@ GameFrame::GameFrame(wxWindow *p_parent, GameDocument *p_doc) MakeMenus(); MakeToolbar(); - wxAcceleratorEntry entries[8]; + wxAcceleratorEntry entries[10]; entries[0].Set(wxACCEL_CTRL, 'o', wxID_OPEN); entries[1].Set(wxACCEL_CTRL, 's', wxID_SAVE); entries[2].Set(wxACCEL_CTRL | wxACCEL_SHIFT, 's', wxID_SAVEAS); @@ -257,8 +257,10 @@ GameFrame::GameFrame(wxWindow *p_parent, GameDocument *p_doc) entries[4].Set(wxACCEL_CTRL, 'w', wxID_CLOSE); entries[5].Set(wxACCEL_CTRL, 'q', wxID_EXIT); entries[6].Set(wxACCEL_CTRL, '+', GBT_MENU_VIEW_ZOOMIN); - entries[7].Set(wxACCEL_CTRL, '-', GBT_MENU_VIEW_ZOOMOUT); - const wxAcceleratorTable accel(8, entries); + entries[7].Set(wxACCEL_CTRL, '=', GBT_MENU_VIEW_ZOOMIN); + entries[8].Set(wxACCEL_CTRL, '-', GBT_MENU_VIEW_ZOOMOUT); + entries[9].Set(wxACCEL_CTRL, '0', GBT_MENU_VIEW_ZOOM100); + const wxAcceleratorTable accel(10, entries); wxWindowBase::SetAcceleratorTable(accel); m_splitter = new wxSplitterWindow(this, wxID_ANY); @@ -342,8 +344,16 @@ void GameFrame::OnUpdate() } menuBar->Check(GBT_MENU_VIEW_PROFILES, m_splitter->IsSplit()); GetToolBar()->ToggleTool(GBT_MENU_VIEW_PROFILES, m_splitter->IsSplit()); - menuBar->Enable(GBT_MENU_VIEW_ZOOMIN, m_efgPanel && m_efgPanel->IsShown()); - menuBar->Enable(GBT_MENU_VIEW_ZOOMOUT, m_efgPanel && m_efgPanel->IsShown()); + + const bool canZoomTree = m_efgPanel && m_efgPanel->IsShown(); + menuBar->Enable(GBT_MENU_VIEW_ZOOMIN, canZoomTree); + menuBar->Enable(GBT_MENU_VIEW_ZOOMOUT, canZoomTree); + menuBar->Enable(GBT_MENU_VIEW_ZOOMFIT, canZoomTree); + menuBar->Enable(GBT_MENU_VIEW_ZOOM100, canZoomTree); + + GetToolBar()->EnableTool(GBT_MENU_VIEW_ZOOMIN, canZoomTree); + GetToolBar()->EnableTool(GBT_MENU_VIEW_ZOOMOUT, canZoomTree); + GetToolBar()->EnableTool(GBT_MENU_VIEW_ZOOMFIT, canZoomTree); } //-------------------------------------------------------------------- @@ -472,13 +482,13 @@ void GameFrame::MakeMenus() viewMenu->Check(GBT_MENU_VIEW_PROFILES, false); viewMenu->AppendSeparator(); - AppendBitmapItem(viewMenu, GBT_MENU_VIEW_ZOOMIN, _("Zoom &in"), + AppendBitmapItem(viewMenu, GBT_MENU_VIEW_ZOOMIN, _("Zoom &In\tCtrl-+"), _("Increase display magnification"), wxBitmap(zoomin_xpm)); - AppendBitmapItem(viewMenu, GBT_MENU_VIEW_ZOOMOUT, _("Zoom &out"), + AppendBitmapItem(viewMenu, GBT_MENU_VIEW_ZOOMOUT, _("Zoom &Out\tCtrl--"), _("Decrease display magnification"), wxBitmap(zoomout_xpm)); - AppendBitmapItem(viewMenu, GBT_MENU_VIEW_ZOOM100, _("&Zoom 1:1"), _("Set magnification to 1:1"), - wxBitmap(zoom1_xpm)); - AppendBitmapItem(viewMenu, GBT_MENU_VIEW_ZOOMFIT, _("&Fit tree to window"), + AppendBitmapItem(viewMenu, GBT_MENU_VIEW_ZOOM100, _("&Actual Size\tCtrl-0"), + _("Set magnification to 1:1"), wxBitmap(zoom1_xpm)); + AppendBitmapItem(viewMenu, GBT_MENU_VIEW_ZOOMFIT, _("Zoom to &Fit"), _("Rescale to show entire tree in window"), wxBitmap(zoomfit_xpm)); viewMenu->AppendSeparator(); @@ -507,7 +517,7 @@ void GameFrame::MakeMenus() AppendBitmapItem(toolsMenu, GBT_MENU_TOOLS_EQUILIBRIUM, _("&Equilibrium"), _("Compute Nash equilibria and refinements"), wxBitmap(calc_xpm)); - toolsMenu->Append(GBT_MENU_TOOLS_QRE, _("&Qre"), _("Compute quantal response equilibria")); + toolsMenu->Append(GBT_MENU_TOOLS_QRE, _("&QRE"), _("Compute quantal response equilibria")); auto *helpMenu = new wxMenu; AppendBitmapItem(helpMenu, wxID_ABOUT, _("&About Gambit"), _("About Gambit"), @@ -563,9 +573,10 @@ void GameFrame::MakeToolbar() wxITEM_NORMAL, _("Zoom in"), _("Increase magnification"), nullptr); toolBar->AddTool(GBT_MENU_VIEW_ZOOMOUT, wxEmptyString, wxBitmap(zoomout_xpm), wxNullBitmap, wxITEM_NORMAL, _("Zoom out"), _("Decrease magnification"), nullptr); + toolBar->AddTool(GBT_MENU_VIEW_ZOOM100, wxEmptyString, wxBitmap(zoom1_xpm), wxNullBitmap, + wxITEM_NORMAL, _("Actual size"), _("Set magnification to 1:1"), nullptr); toolBar->AddTool(GBT_MENU_VIEW_ZOOMFIT, wxEmptyString, wxBitmap(zoomfit_xpm), wxNullBitmap, - wxITEM_NORMAL, _("Fit to window"), _("Set magnification to see entrie tree"), - nullptr); + wxITEM_NORMAL, _("Zoom to fit"), _("Fit the tree in the window"), nullptr); } toolBar->AddSeparator(); @@ -585,7 +596,7 @@ void GameFrame::MakeToolbar() _("Display the reduced strategic representation of the game"), nullptr); } toolBar->AddTool(GBT_MENU_VIEW_PROFILES, wxEmptyString, wxBitmap(profiles_xpm), wxNullBitmap, - wxITEM_NORMAL, _("View the list of computed strategy profiles"), + wxITEM_CHECK, _("View the list of computed strategy profiles"), _("Show or hide the list of computed strategy profiles"), nullptr); toolBar->AddTool(GBT_MENU_TOOLS_EQUILIBRIUM, wxEmptyString, wxBitmap(calc_xpm), wxNullBitmap, wxITEM_NORMAL, _("Compute Nash equilibria of this game"), @@ -1058,7 +1069,9 @@ void GameFrame::OnViewProfiles(wxCommandEvent &p_event) void GameFrame::OnViewZoom(wxCommandEvent &p_event) { // All zoom events get passed along to the panel - wxPostEvent(m_efgPanel, p_event); + if (m_efgPanel && m_efgPanel->IsShown()) { + wxPostEvent(m_efgPanel, p_event); + } } void GameFrame::OnViewStrategic(wxCommandEvent &p_event) @@ -1112,15 +1125,14 @@ void GameFrame::OnViewStrategic(wxCommandEvent &p_event) m_efgPanel->SetFocus(); } + const bool canZoomTree = m_efgPanel && m_efgPanel->IsShown(); + GetMenuBar()->Check(GBT_MENU_VIEW_STRATEGIC, m_nfgPanel->IsShown()); - GetMenuBar()->Enable(GBT_MENU_VIEW_ZOOMIN, !p_event.IsChecked()); - GetMenuBar()->Enable(GBT_MENU_VIEW_ZOOMOUT, !p_event.IsChecked()); GetMenuBar()->Enable(GBT_MENU_TOOLS_DOMINANCE, m_nfgPanel->IsShown()); GetToolBar()->ToggleTool(GBT_MENU_VIEW_STRATEGIC, p_event.IsChecked()); - GetToolBar()->EnableTool(GBT_MENU_VIEW_ZOOMIN, !p_event.IsChecked()); - GetToolBar()->EnableTool(GBT_MENU_VIEW_ZOOMOUT, !p_event.IsChecked()); - GetToolBar()->EnableTool(GBT_MENU_VIEW_ZOOMFIT, !p_event.IsChecked()); + + OnUpdate(); } //----------------------------------------------------------------------