diff --git a/ChangeLog b/ChangeLog index cb4b02ea81..b12b97ddca 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,8 @@ ## [17.0.0-beta.2] - unreleased ### Added +- The "Open existing game" button on the GUI's welcome screen now has a dropdown listing the most + recently opened games. (#1108) - Details on bifurcations found and actions taken to resolve them (by perturbation) are now reported by `nash.logit_solve` (and related functions) (#186) diff --git a/src/gui/app.cc b/src/gui/app.cc index 309ceed6b2..05a8e8b0f1 100644 --- a/src/gui/app.cc +++ b/src/gui/app.cc @@ -26,6 +26,7 @@ #endif // WX_PRECOMP #include #include +#include #include #include "games.h" @@ -183,9 +184,17 @@ AppLoadResult Application::LoadFile(const wxString &p_filename, wxWindow *p_pare switch (result) { case GameDocument::LoadResult::OpenFailed: - FileErrorDialog(p_parent, _("Unable to open file"), - _("Gambit could not open this file for reading."), p_filename) - .ShowModal(); + if (!wxFileName::FileExists(p_filename) && ForgetRecentFile(p_filename)) { + FileErrorDialog(p_parent, _("File not found"), _("This file no longer exists."), + p_filename + "\n\n" + + _("It has been removed from the list of recent games.")) + .ShowModal(); + } + else { + FileErrorDialog(p_parent, _("Unable to open file"), + _("Gambit could not open this file for reading."), p_filename) + .ShowModal(); + } return AppLoadResult::OpenFailed; case GameDocument::LoadResult::ParseFailed: @@ -210,6 +219,27 @@ AppLoadResult Application::LoadFile(const wxString &p_filename, wxWindow *p_pare } } +std::vector Application::GetRecentFiles() const +{ + std::vector files; + for (size_t i = 0; i < m_fileHistory.GetCount(); i++) { + files.push_back(m_fileHistory.GetHistoryFile(i)); + } + return files; +} + +bool Application::ForgetRecentFile(const wxString &p_filename) +{ + for (size_t i = 0; i < m_fileHistory.GetCount(); i++) { + if (m_fileHistory.GetHistoryFile(i) == p_filename) { + m_fileHistory.RemoveFileFromHistory(i); + m_fileHistory.Save(*wxConfigBase::Get()); + return true; + } + } + return false; +} + void Application::SetCurrentDir(const wxString &p_dir) { m_currentDir = p_dir; diff --git a/src/gui/app.h b/src/gui/app.h index bdb02b64b0..b4bb763297 100644 --- a/src/gui/app.h +++ b/src/gui/app.h @@ -23,6 +23,8 @@ #ifndef GAMBIT_GUI_APP_H #define GAMBIT_GUI_APP_H +#include + #include #include // for wxConfig #include // for wxFileHistory @@ -50,6 +52,8 @@ class Application final : public wxApp { bool OnInit() override; void DismissSplash(); void OnSplashDismissTimer(wxTimerEvent &) { DismissSplash(); } + //! Drop `p_filename` from the recently-used list; returns whether it was there. + bool ForgetRecentFile(const wxString &p_filename); wxDECLARE_EVENT_TABLE(); @@ -61,6 +65,8 @@ class Application final : public wxApp { void SetCurrentDir(const wxString &p_dir); wxString GetHistoryFile(int index) const { return m_fileHistory.GetHistoryFile(index); } + //! The recently-used files, most recent first. + std::vector GetRecentFiles() const; void AddMenu(wxMenu *p_menu) { m_fileHistory.UseMenu(p_menu); diff --git a/src/gui/welcome.cc b/src/gui/welcome.cc index 1ea42f35d9..b5267bdb48 100644 --- a/src/gui/welcome.cc +++ b/src/gui/welcome.cc @@ -1,10 +1,19 @@ +#include +#include #include #include +#include +#include #include #include #include +#include #include +#include +#include +#include +#include #include "games.h" @@ -19,11 +28,57 @@ using namespace Gambit::GUI; wxDEFINE_EVENT(wxEVT_WELCOME_OPEN, wxCommandEvent); wxDEFINE_EVENT(wxEVT_WELCOME_NEW, wxCommandEvent); +wxDEFINE_EVENT(wxEVT_WELCOME_OPEN_RECENT, wxCommandEvent); namespace { constexpr int ID_WELCOME_OPEN = wxID_HIGHEST + 100; constexpr int ID_WELCOME_NEW_NORMAL_FORM = wxID_HIGHEST + 101; constexpr int ID_WELCOME_NEW_EXTENSIVE_FORM = wxID_HIGHEST + 102; +constexpr int ID_WELCOME_RECENT = wxID_HIGHEST + 103; + +constexpr int BUTTON_WIDTH = 340; +constexpr int BUTTON_HEIGHT = 72; +/// The width of the dropdown part of the "Open existing game" split button. +constexpr int RECENT_BUTTON_WIDTH = 36; + +/// Write the user's home directory as "~". +wxString AbbreviateHomeDirectory(const wxString &p_path) +{ + const wxString homeDir = wxFileName::GetHomeDir(); + if (!homeDir.empty() && + (p_path == homeDir || p_path.StartsWith(homeDir + wxFileName::GetPathSeparator()))) { + return wxT("~") + p_path.Mid(homeDir.length()); + } + return p_path; +} + +/// A downward-pointing triangle of the given size, on a transparent background. +wxBitmap DrawDropArrow(int p_size, const wxColour &p_colour) +{ + wxBitmap bitmap(p_size, p_size, 32); + bitmap.UseAlpha(); + { + wxMemoryDC dc(bitmap); + dc.SetBackground(*wxTRANSPARENT_BRUSH); + dc.Clear(); + wxGCDC gc(dc); + gc.SetPen(*wxTRANSPARENT_PEN); + gc.SetBrush(wxBrush(p_colour)); + // Ten wide by five high, centred, in units of a 24-pixel canvas. + const wxPoint points[] = {wxPoint(p_size * 7 / 24, p_size * 10 / 24), + wxPoint(p_size * 17 / 24, p_size * 10 / 24), + wxPoint(p_size * 12 / 24, p_size * 15 / 24)}; + gc.DrawPolygon(3, points); + } + return bitmap; +} + +/// The marker for the dropdown half of the "Open" split button. +wxBitmapBundle MakeDropArrowBitmap() +{ + const wxColour colour = wxSystemSettings::GetColour(wxSYS_COLOUR_BTNTEXT); + return wxBitmapBundle::FromBitmaps(DrawDropArrow(24, colour), DrawDropArrow(48, colour)); +} } // namespace // -------------------- @@ -64,6 +119,218 @@ wxBitmap MakeScaledBitmap(const char *const *xpm, int targetHeight) } // namespace +// -------------------- +// RecentFilesPopup +// -------------------- + +/// The rows of the recent-files dropdown: the file name on the left, its directory on the right. +class RecentFilesList : public wxVListBox { +public: + RecentFilesList(RecentFilesPopup *p_popup, int p_width); + + void SetFiles(const std::vector &p_files); + const wxString &GetFile(size_t n) const { return m_files[n]; } + +protected: + void OnDrawItem(wxDC &dc, const wxRect &rect, size_t n) const override; + void OnDrawBackground(wxDC &dc, const wxRect &rect, size_t n) const override; + wxCoord OnMeasureItem(size_t) const override { return m_rowHeight; } + +private: + void OnMotion(wxMouseEvent &p_event); + void OnLeftUp(wxMouseEvent &p_event); + void OnKeyDown(wxKeyEvent &p_event); + + RecentFilesPopup *m_popup; + std::vector m_files; + int m_rowHeight; +}; + +/// The dropdown attached to the "Open existing game" button, listing the recently-used files. +class RecentFilesPopup : public wxPopupTransientWindow { +public: + RecentFilesPopup(WelcomePanel *p_panel, int p_width); + + /// Re-read the application's file history; returns false if there is nothing to show. + bool Populate(); + /// Show the dropdown just below the given rectangle, given in screen coordinates. + void ShowBelow(const wxRect &p_anchor); + /// Open the n-th listed file. + void Activate(size_t n); + /// Whether the dropdown was dismissed a moment ago, e.g. by the click now being handled. + bool WasJustDismissed() const; + +protected: + void OnDismiss() override; + +private: + WelcomePanel *m_panel; + RecentFilesList *m_list{nullptr}; + wxLongLong m_dismissedAt{0}; +}; + +RecentFilesList::RecentFilesList(RecentFilesPopup *p_popup, int p_width) + : wxVListBox(p_popup, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE), + m_popup(p_popup), m_rowHeight(GetCharHeight() + 12) +{ + const wxColour window = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); + SetBackgroundColour(window); + // Highlight the row under the mouse with a light touch of the window colour. + SetSelectionBackground( + window.ChangeLightness(wxSystemSettings::GetAppearance().IsDark() ? 115 : 92)); + SetMinSize(wxSize(p_width, m_rowHeight)); + Bind(wxEVT_MOTION, &RecentFilesList::OnMotion, this); + Bind(wxEVT_LEFT_UP, &RecentFilesList::OnLeftUp, this); + Bind(wxEVT_KEY_DOWN, &RecentFilesList::OnKeyDown, this); +} + +void RecentFilesList::SetFiles(const std::vector &p_files) +{ + m_files = p_files; + SetItemCount(m_files.size()); + SetMinSize(wxSize(GetMinWidth(), static_cast(m_files.size()) * m_rowHeight)); + if (!m_files.empty()) { + SetSelection(0); + } + RefreshAll(); +} + +void RecentFilesList::OnDrawBackground(wxDC &dc, const wxRect &rect, size_t n) const +{ + if (IsSelected(n)) { + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(GetSelectionBackground())); + dc.DrawRectangle(rect); + } +} + +void RecentFilesList::OnDrawItem(wxDC &dc, const wxRect &rect, size_t n) const +{ + constexpr int margin = 12; + constexpr int gap = 16; + const wxFileName path(m_files[n]); + + dc.SetFont(GetFont()); + + // The name gets at most three-fifths of the row and is shortened in the middle. + const wxString name = + wxControl::Ellipsize(path.GetFullName(), dc, wxELLIPSIZE_MIDDLE, + (rect.width - 2 * margin) * 3 / 5, wxELLIPSIZE_FLAGS_NONE); + const wxSize nameSize = dc.GetTextExtent(name); + dc.SetTextForeground(GetForegroundColour()); + dc.DrawText(name, rect.x + margin, rect.y + (rect.height - nameSize.y) / 2); + + const int directoryWidth = rect.width - 2 * margin - nameSize.x - gap; + if (directoryWidth <= 0) { + return; + } + const wxString directory = + wxControl::Ellipsize(AbbreviateHomeDirectory(path.GetPath()), dc, wxELLIPSIZE_START, + directoryWidth, wxELLIPSIZE_FLAGS_NONE); + const wxSize directorySize = dc.GetTextExtent(directory); + dc.SetTextForeground(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); + dc.DrawText(directory, rect.x + rect.width - margin - directorySize.x, + rect.y + (rect.height - directorySize.y) / 2); +} + +void RecentFilesList::OnMotion(wxMouseEvent &p_event) +{ + // Follow the mouse with the highlight, as a menu does. + const int item = VirtualHitTest(p_event.GetPosition().y); + if (item == wxNOT_FOUND) { + UnsetToolTip(); + } + else { + SetSelection(item); + if (GetToolTipText() != m_files[item]) { + SetToolTip(m_files[item]); + } + } + p_event.Skip(); +} + +void RecentFilesList::OnLeftUp(wxMouseEvent &p_event) +{ + const int item = VirtualHitTest(p_event.GetPosition().y); + if (item != wxNOT_FOUND) { + m_popup->Activate(static_cast(item)); + } + else { + p_event.Skip(); + } +} + +void RecentFilesList::OnKeyDown(wxKeyEvent &p_event) +{ + switch (p_event.GetKeyCode()) { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (GetSelection() != wxNOT_FOUND) { + m_popup->Activate(static_cast(GetSelection())); + } + break; + case WXK_ESCAPE: + m_popup->Dismiss(); + break; + default: + // Up, Down, Home and End are handled by wxVListBox itself. + p_event.Skip(); + } +} + +RecentFilesPopup::RecentFilesPopup(WelcomePanel *p_panel, int p_width) + : wxPopupTransientWindow(p_panel, wxBORDER_SIMPLE), m_panel(p_panel) +{ + SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + auto *heading = new wxStaticText(this, wxID_ANY, _("Recent games")); + auto headingFont = heading->GetFont(); + headingFont.MakeSmaller(); + heading->SetFont(headingFont); + heading->SetForegroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); + + m_list = new RecentFilesList(this, p_width); + + auto *sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(heading, 0, wxLEFT | wxRIGHT | wxTOP, 12); + sizer->Add(m_list, 0, wxEXPAND | wxTOP | wxBOTTOM, 6); + SetSizer(sizer); +} + +bool RecentFilesPopup::Populate() +{ + const auto files = wxGetApp().GetRecentFiles(); + m_list->SetFiles(files); + GetSizer()->Fit(this); + return !files.empty(); +} + +void RecentFilesPopup::ShowBelow(const wxRect &p_anchor) +{ + // Position() puts the popup below and to the *right* of the rectangle it is given. + Position(p_anchor.GetTopLeft(), wxSize(0, p_anchor.GetHeight())); + Popup(m_list); +} + +void RecentFilesPopup::Activate(size_t n) +{ + Dismiss(); + // Deferred so that the dropdown has fully closed before any dialog can come up. + m_panel->CallAfter( + [panel = m_panel, filename = m_list->GetFile(n)]() { panel->OpenRecentFile(filename); }); +} + +void RecentFilesPopup::OnDismiss() { m_dismissedAt = wxGetUTCTimeMillis(); } + +bool RecentFilesPopup::WasJustDismissed() const +{ + return wxGetUTCTimeMillis() - m_dismissedAt < 250; +} + +// -------------------- +// WelcomePanel +// -------------------- + void WelcomePanel::CreateControls() { m_logoBitmap = new wxStaticBitmap(this, wxID_ANY, MakeScaledBitmap(gambitbig_xpm, 72)); @@ -94,14 +361,28 @@ void WelcomePanel::CreateControls() m_newExtensiveFormButton->SetBitmap(wxBitmapBundle::FromSVG(newtree_svg, wxSize(24, 24))); m_newExtensiveFormButton->SetBitmapMargins(24, 12); - const wxSize buttonSize(340, 72); - m_openButton->SetMinSize(buttonSize); - m_newNormalFormButton->SetMinSize(buttonSize); - m_newExtensiveFormButton->SetMinSize(buttonSize); + // The "Open" button is a split button: the dropdown to its right lists recent files. + m_openButton->SetMinSize(wxSize(BUTTON_WIDTH - RECENT_BUTTON_WIDTH, BUTTON_HEIGHT)); + m_newNormalFormButton->SetMinSize(wxSize(BUTTON_WIDTH, BUTTON_HEIGHT)); + m_newExtensiveFormButton->SetMinSize(wxSize(BUTTON_WIDTH, BUTTON_HEIGHT)); + + m_recentButton = new wxButton(this, ID_WELCOME_RECENT, wxEmptyString, wxDefaultPosition, + wxDefaultSize, wxBU_EXACTFIT); + m_recentButton->SetBitmap(MakeDropArrowBitmap()); + m_recentButton->SetMinSize(wxSize(RECENT_BUTTON_WIDTH, BUTTON_HEIGHT)); + RefreshRecentFiles(); Bind(wxEVT_BUTTON, &WelcomePanel::OnOpen, this, ID_WELCOME_OPEN); Bind(wxEVT_BUTTON, &WelcomePanel::OnNewNormalForm, this, ID_WELCOME_NEW_NORMAL_FORM); Bind(wxEVT_BUTTON, &WelcomePanel::OnNewExtensiveForm, this, ID_WELCOME_NEW_EXTENSIVE_FORM); + Bind(wxEVT_BUTTON, &WelcomePanel::OnShowRecent, this, ID_WELCOME_RECENT); +} + +void WelcomePanel::RefreshRecentFiles() +{ + const bool hasRecentFiles = !wxGetApp().GetRecentFiles().empty(); + m_recentButton->Enable(hasRecentFiles); + m_recentButton->SetToolTip(hasRecentFiles ? _("Recent games") : _("No recent games")); } void WelcomePanel::LayoutControls() @@ -113,7 +394,11 @@ void WelcomePanel::LayoutControls() content->Add(m_logoBitmap, 0, wxALIGN_CENTER_HORIZONTAL | wxBOTTOM, 16); content->Add(m_titleText, 0, wxALIGN_CENTER_HORIZONTAL | wxBOTTOM, 20); - content->Add(m_openButton, 0, wxEXPAND | wxBOTTOM, 16); + auto *openRow = new wxBoxSizer(wxHORIZONTAL); + openRow->Add(m_openButton, 1, wxEXPAND); + openRow->Add(m_recentButton, 0, wxEXPAND); + + content->Add(openRow, 0, wxEXPAND | wxBOTTOM, 16); content->Add(m_newNormalFormButton, 0, wxEXPAND | wxBOTTOM, 16); content->Add(m_newExtensiveFormButton, 0, wxEXPAND, 0); @@ -140,6 +425,29 @@ void WelcomePanel::OnNewExtensiveForm(wxCommandEvent &) SendNewEvent(WelcomeNewProblemKind::ExtensiveForm); } +void WelcomePanel::OnShowRecent(wxCommandEvent &) +{ + if (!m_recentPopup) { + m_recentPopup = new RecentFilesPopup(this, BUTTON_WIDTH); + } + if (m_recentPopup->WasJustDismissed()) { + return; + } + if (!m_recentPopup->Populate()) { + RefreshRecentFiles(); + return; + } + m_recentPopup->ShowBelow(m_openButton->GetScreenRect()); +} + +void WelcomePanel::OpenRecentFile(const wxString &p_filename) +{ + wxCommandEvent event(wxEVT_WELCOME_OPEN_RECENT); + event.SetEventObject(this); + event.SetString(p_filename); + GetParent()->ProcessWindowEvent(event); +} + void WelcomePanel::SendOpenEvent() { wxCommandEvent event(wxEVT_WELCOME_OPEN); @@ -168,6 +476,7 @@ WelcomeFrame::WelcomeFrame(wxWindow *parent) Bind(wxEVT_WELCOME_OPEN, &WelcomeFrame::OnWelcomeOpen, this); Bind(wxEVT_WELCOME_NEW, &WelcomeFrame::OnWelcomeNew, this); + Bind(wxEVT_WELCOME_OPEN_RECENT, &WelcomeFrame::OnWelcomeOpenRecent, this); Bind(wxEVT_CLOSE_WINDOW, &WelcomeFrame::OnClose, this); CentreOnScreen(); @@ -198,6 +507,13 @@ void WelcomeFrame::OnWelcomeNew(wxCommandEvent &p_event) } } +void WelcomeFrame::OnWelcomeOpenRecent(wxCommandEvent &p_event) +{ + if (DoOpenRecent(p_event.GetString())) { + Destroy(); + } +} + void WelcomeFrame::OnClose(wxCloseEvent &p_event) { p_event.Skip(); } bool WelcomeFrame::DoOpen() @@ -215,6 +531,16 @@ bool WelcomeFrame::DoOpen() return wxGetApp().LoadFile(filename, this) == AppLoadResult::Success; } +bool WelcomeFrame::DoOpenRecent(const wxString &p_filename) +{ + if (wxGetApp().LoadFile(p_filename, this) != AppLoadResult::Success) { + m_panel->RefreshRecentFiles(); + return false; + } + wxGetApp().SetCurrentDir(wxPathOnly(p_filename)); + return true; +} + bool WelcomeFrame::DoCreateNew(WelcomeNewProblemKind p_kind) { switch (p_kind) { diff --git a/src/gui/welcome.h b/src/gui/welcome.h index c74d484a0d..4d91a14d0e 100644 --- a/src/gui/welcome.h +++ b/src/gui/welcome.h @@ -9,17 +9,21 @@ class wxButton; class wxStaticText; -class wxBoxSizer; +class RecentFilesPopup; enum class WelcomeNewProblemKind { NormalForm, ExtensiveForm }; wxDECLARE_EVENT(wxEVT_WELCOME_OPEN, wxCommandEvent); wxDECLARE_EVENT(wxEVT_WELCOME_NEW, wxCommandEvent); +wxDECLARE_EVENT(wxEVT_WELCOME_OPEN_RECENT, wxCommandEvent); class WelcomePanel : public wxPanel { public: explicit WelcomePanel(wxWindow *parent); + void RefreshRecentFiles(); + void OpenRecentFile(const wxString &p_filename); + private: void CreateControls(); void LayoutControls(); @@ -27,6 +31,7 @@ class WelcomePanel : public wxPanel { void OnOpen(wxCommandEvent &p_event); void OnNewNormalForm(wxCommandEvent &p_event); void OnNewExtensiveForm(wxCommandEvent &p_event); + void OnShowRecent(wxCommandEvent &p_event); void SendOpenEvent(); void SendNewEvent(WelcomeNewProblemKind p_kind); @@ -35,8 +40,11 @@ class WelcomePanel : public wxPanel { wxStaticText *m_titleText{nullptr}; wxCommandLinkButton *m_openButton{nullptr}; + wxButton *m_recentButton{nullptr}; wxCommandLinkButton *m_newNormalFormButton{nullptr}; wxCommandLinkButton *m_newExtensiveFormButton{nullptr}; + + RecentFilesPopup *m_recentPopup{nullptr}; }; class WelcomeFrame : public wxFrame { @@ -49,9 +57,11 @@ class WelcomeFrame : public wxFrame { void OnWelcomeOpen(wxCommandEvent &p_event); void OnWelcomeNew(wxCommandEvent &p_event); + void OnWelcomeOpenRecent(wxCommandEvent &p_event); void OnClose(wxCloseEvent &p_event); bool DoOpen(); + bool DoOpenRecent(const wxString &p_filename); bool DoCreateNew(WelcomeNewProblemKind p_kind); private: