From 9ff478798a9377fbcb72865451c1dbbeef1674cf Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 4 Sep 2026 14:25:33 +0100 Subject: [PATCH 1/3] Add recently-used file list to the welcome screen --- ChangeLog | 6 ++ src/gui/app.h | 6 ++ src/gui/welcome.cc | 149 ++++++++++++++++++++++++++++++++++++++++++++- src/gui/welcome.h | 16 +++++ 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/ChangeLog b/ChangeLog index f4d3e20bf4..c84ee05f16 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,9 @@ +## [17.0.0-beta.2] - unreleased + +### Added +- The GUI's welcome screen now lists the most recently opened games, so one can be reopened + with a click; entries whose files no longer exist are dropped from the list when chosen. (#1108) + ## [17.0.0-beta.1] - 2026-09-08 ### Added diff --git a/src/gui/app.h b/src/gui/app.h index bdb02b64b0..428d64f9a9 100644 --- a/src/gui/app.h +++ b/src/gui/app.h @@ -60,7 +60,13 @@ class Application final : public wxApp { const wxString &GetCurrentDir() { return m_currentDir; } void SetCurrentDir(const wxString &p_dir); + size_t GetHistoryCount() const { return m_fileHistory.GetCount(); } wxString GetHistoryFile(int index) const { return m_fileHistory.GetHistoryFile(index); } + void RemoveHistoryFile(int index) + { + m_fileHistory.RemoveFileFromHistory(index); + m_fileHistory.Save(*wxConfigBase::Get()); + } void AddMenu(wxMenu *p_menu) { m_fileHistory.UseMenu(p_menu); diff --git a/src/gui/welcome.cc b/src/gui/welcome.cc index 1ea42f35d9..0542326330 100644 --- a/src/gui/welcome.cc +++ b/src/gui/welcome.cc @@ -1,10 +1,17 @@ +#include + #include #include +#include #include #include #include +#include +#include #include +#include +#include #include "games.h" @@ -19,11 +26,29 @@ 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; + +/// The application tracks more recently-used files, showing only the first few. +constexpr int MAX_RECENT_FILES = 5; +constexpr int ID_WELCOME_RECENT_FIRST = wxID_HIGHEST + 110; +constexpr int ID_WELCOME_RECENT_LAST = ID_WELCOME_RECENT_FIRST + MAX_RECENT_FILES - 1; + +/// Drop `p_filename` from the application's most-recently-used list, if it is there. +void RemoveFromFileHistory(const wxString &p_filename) +{ + const auto count = static_cast(wxGetApp().GetHistoryCount()); + for (int i = 0; i < count; i++) { + if (wxGetApp().GetHistoryFile(i) == p_filename) { + wxGetApp().RemoveHistoryFile(i); + return; + } + } +} } // namespace // -------------------- @@ -99,9 +124,74 @@ void WelcomePanel::CreateControls() m_newNormalFormButton->SetMinSize(buttonSize); m_newExtensiveFormButton->SetMinSize(buttonSize); + m_recentLine = new wxStaticLine(this); + m_recentHeading = new wxStaticText(this, wxID_ANY, _("Recent games")); + auto headingFont = m_recentHeading->GetFont(); + headingFont.SetWeight(wxFONTWEIGHT_BOLD); + m_recentHeading->SetFont(headingFont); + + m_recentPanel = new wxPanel(this); + 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_HYPERLINK, &WelcomePanel::OnOpenRecent, this, ID_WELCOME_RECENT_FIRST, + ID_WELCOME_RECENT_LAST); +} + +bool WelcomePanel::RefreshRecentFiles() +{ + m_recentFiles.clear(); + m_recentPanel->DestroyChildren(); + + auto *sizer = new wxBoxSizer(wxVERTICAL); + + const auto count = std::min(static_cast(wxGetApp().GetHistoryCount()), MAX_RECENT_FILES); + for (int i = 0; i < count; i++) { + const wxString filename = wxGetApp().GetHistoryFile(i); + if (filename.empty()) { + continue; + } + + const wxFileName path(filename); + const auto id = ID_WELCOME_RECENT_FIRST + static_cast(m_recentFiles.size()); + + auto *row = new wxBoxSizer(wxHORIZONTAL); + + const int rowWidth = m_openButton->GetMinSize().GetWidth(); + const int gap = 8; + const wxClientDC dc(m_recentPanel); + const wxString name = wxControl::Ellipsize(path.GetFullName(), dc, wxELLIPSIZE_MIDDLE, + rowWidth * 2 / 3, wxELLIPSIZE_FLAGS_NONE); + + auto *link = + new wxHyperlinkCtrl(m_recentPanel, id, wxControl::EscapeMnemonics(name), wxEmptyString, + wxDefaultPosition, wxDefaultSize, wxHL_ALIGN_LEFT | wxNO_BORDER); + link->SetVisitedColour(link->GetNormalColour()); + link->SetToolTip(filename); + row->Add(link, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); + + auto *directory = new wxStaticText(m_recentPanel, wxID_ANY, wxEmptyString); + directory->SetLabelText(wxControl::Ellipsize(path.GetPath(), dc, wxELLIPSIZE_MIDDLE, + rowWidth - link->GetBestSize().GetWidth() - gap, + wxELLIPSIZE_FLAGS_NONE)); + directory->SetForegroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); + directory->SetToolTip(filename); + row->Add(directory, 0, wxALIGN_CENTER_VERTICAL); + + sizer->Add(row, 0, wxBOTTOM, 4); + m_recentFiles.push_back(filename); + } + + m_recentPanel->SetSizer(sizer); + m_recentPanel->Layout(); + + const bool hasRecentFiles = !m_recentFiles.empty(); + m_recentLine->Show(hasRecentFiles); + m_recentHeading->Show(hasRecentFiles); + m_recentPanel->Show(hasRecentFiles); + return hasRecentFiles; } void WelcomePanel::LayoutControls() @@ -117,6 +207,11 @@ void WelcomePanel::LayoutControls() content->Add(m_newNormalFormButton, 0, wxEXPAND | wxBOTTOM, 16); content->Add(m_newExtensiveFormButton, 0, wxEXPAND, 0); + // Hidden, and contributing no height, when there is no file history to show. + content->Add(m_recentLine, 0, wxEXPAND | wxTOP | wxBOTTOM, 20); + content->Add(m_recentHeading, 0, wxBOTTOM, 8); + content->Add(m_recentPanel, 0, wxEXPAND); + row->AddStretchSpacer(1); row->Add(content, 0, wxEXPAND | wxLEFT | wxRIGHT, 24); row->AddStretchSpacer(1); @@ -140,6 +235,14 @@ void WelcomePanel::OnNewExtensiveForm(wxCommandEvent &) SendNewEvent(WelcomeNewProblemKind::ExtensiveForm); } +void WelcomePanel::OnOpenRecent(wxCommandEvent &p_event) +{ + const auto index = static_cast(p_event.GetId() - ID_WELCOME_RECENT_FIRST); + if (index < m_recentFiles.size()) { + SendOpenRecentEvent(m_recentFiles[index]); + } +} + void WelcomePanel::SendOpenEvent() { wxCommandEvent event(wxEVT_WELCOME_OPEN); @@ -147,6 +250,14 @@ void WelcomePanel::SendOpenEvent() GetParent()->ProcessWindowEvent(event); } +void WelcomePanel::SendOpenRecentEvent(const wxString &p_filename) +{ + wxCommandEvent event(wxEVT_WELCOME_OPEN_RECENT); + event.SetEventObject(this); + event.SetString(p_filename); + GetParent()->ProcessWindowEvent(event); +} + void WelcomePanel::SendNewEvent(WelcomeNewProblemKind p_kind) { wxCommandEvent event(wxEVT_WELCOME_NEW); @@ -160,7 +271,7 @@ void WelcomePanel::SendNewEvent(WelcomeNewProblemKind p_kind) // -------------------- WelcomeFrame::WelcomeFrame(wxWindow *parent) - : wxFrame(parent, wxID_ANY, wxT("Gambit"), wxDefaultPosition, wxSize(600, 500), + : wxFrame(parent, wxID_ANY, wxT("Gambit"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_FRAME_STYLE & ~(wxRESIZE_BORDER | wxMAXIMIZE_BOX)) { CreateControls(); @@ -168,6 +279,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(); @@ -180,6 +292,15 @@ void WelcomeFrame::LayoutControls() auto *topSizer = new wxBoxSizer(wxVERTICAL); topSizer->Add(m_panel, 1, wxEXPAND); SetSizer(topSizer); + FitToContents(); +} + +void WelcomeFrame::FitToContents() +{ + const int contentHeight = m_panel->GetSizer()->GetMinSize().GetHeight() + 64; + m_panel->SetMinSize(wxSize(600, std::max(500, contentHeight))); + GetSizer()->SetSizeHints(this); + Layout(); } void WelcomeFrame::OnWelcomeOpen(wxCommandEvent &) @@ -198,6 +319,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 +343,25 @@ bool WelcomeFrame::DoOpen() return wxGetApp().LoadFile(filename, this) == AppLoadResult::Success; } +bool WelcomeFrame::DoOpenRecent(const wxString &p_filename) +{ + if (!wxFileName::FileExists(p_filename)) { + wxMessageBox(wxString::Format(_("Gambit could not find the file %s.\n\n" + "It has been removed from the list of recent games."), + p_filename), + _("File not found"), wxOK | wxICON_EXCLAMATION, this); + RemoveFromFileHistory(p_filename); + CallAfter([this]() { + m_panel->RefreshRecentFiles(); + FitToContents(); + }); + return false; + } + + wxGetApp().SetCurrentDir(wxPathOnly(p_filename)); + return wxGetApp().LoadFile(p_filename, this) == AppLoadResult::Success; +} + bool WelcomeFrame::DoCreateNew(WelcomeNewProblemKind p_kind) { switch (p_kind) { diff --git a/src/gui/welcome.h b/src/gui/welcome.h index c74d484a0d..31d071890b 100644 --- a/src/gui/welcome.h +++ b/src/gui/welcome.h @@ -1,6 +1,8 @@ #ifndef GAMBIT_WELCOME_H #define GAMBIT_WELCOME_H +#include + #include #include #include @@ -9,17 +11,21 @@ class wxButton; class wxStaticText; +class wxStaticLine; class wxBoxSizer; 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); + bool RefreshRecentFiles(); + private: void CreateControls(); void LayoutControls(); @@ -27,9 +33,11 @@ class WelcomePanel : public wxPanel { void OnOpen(wxCommandEvent &p_event); void OnNewNormalForm(wxCommandEvent &p_event); void OnNewExtensiveForm(wxCommandEvent &p_event); + void OnOpenRecent(wxCommandEvent &p_event); void SendOpenEvent(); void SendNewEvent(WelcomeNewProblemKind p_kind); + void SendOpenRecentEvent(const wxString &p_filename); wxStaticBitmap *m_logoBitmap{nullptr}; wxStaticText *m_titleText{nullptr}; @@ -37,6 +45,11 @@ class WelcomePanel : public wxPanel { wxCommandLinkButton *m_openButton{nullptr}; wxCommandLinkButton *m_newNormalFormButton{nullptr}; wxCommandLinkButton *m_newExtensiveFormButton{nullptr}; + + wxStaticLine *m_recentLine{nullptr}; + wxStaticText *m_recentHeading{nullptr}; + wxPanel *m_recentPanel{nullptr}; + std::vector m_recentFiles; }; class WelcomeFrame : public wxFrame { @@ -46,12 +59,15 @@ class WelcomeFrame : public wxFrame { private: void CreateControls(); void LayoutControls(); + void FitToContents(); 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: From 98100456abadbc0594035337e80716d344471e96 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 9 Sep 2026 13:16:41 +0100 Subject: [PATCH 2/3] Show the recent files as a dropdown on the Open button --- ChangeLog | 5 +- src/gui/welcome.cc | 407 ++++++++++++++++++++++++++++++++++----------- src/gui/welcome.h | 18 +- 3 files changed, 315 insertions(+), 115 deletions(-) diff --git a/ChangeLog b/ChangeLog index c84ee05f16..dbacb7b939 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,8 +1,9 @@ ## [17.0.0-beta.2] - unreleased ### Added -- The GUI's welcome screen now lists the most recently opened games, so one can be reopened - with a click; entries whose files no longer exist are dropped from the list when chosen. (#1108) +- The "Open existing game" button on the GUI's welcome screen now has a dropdown listing the most + recently opened games, so one can be reopened with a click; entries whose files no longer exist + are dropped from the list when chosen. (#1108) ## [17.0.0-beta.1] - 2026-09-08 diff --git a/src/gui/welcome.cc b/src/gui/welcome.cc index 0542326330..9d32f1acc1 100644 --- a/src/gui/welcome.cc +++ b/src/gui/welcome.cc @@ -1,17 +1,20 @@ -#include +#include +#include #include #include -#include +#include +#include #include #include #include #include -#include #include +#include #include -#include +#include +#include #include "games.h" @@ -32,11 +35,12 @@ 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; -/// The application tracks more recently-used files, showing only the first few. -constexpr int MAX_RECENT_FILES = 5; -constexpr int ID_WELCOME_RECENT_FIRST = wxID_HIGHEST + 110; -constexpr int ID_WELCOME_RECENT_LAST = ID_WELCOME_RECENT_FIRST + MAX_RECENT_FILES - 1; +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; /// Drop `p_filename` from the application's most-recently-used list, if it is there. void RemoveFromFileHistory(const wxString &p_filename) @@ -49,6 +53,45 @@ void RemoveFromFileHistory(const wxString &p_filename) } } } + +/// 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 // -------------------- @@ -89,6 +132,225 @@ 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() +{ + std::vector files; + const auto count = static_cast(wxGetApp().GetHistoryCount()); + for (int i = 0; i < count; i++) { + const wxString filename = wxGetApp().GetHistoryFile(i); + if (!filename.empty()) { + files.push_back(filename); + } + } + 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)); @@ -119,79 +381,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); - - m_recentLine = new wxStaticLine(this); - m_recentHeading = new wxStaticText(this, wxID_ANY, _("Recent games")); - auto headingFont = m_recentHeading->GetFont(); - headingFont.SetWeight(wxFONTWEIGHT_BOLD); - m_recentHeading->SetFont(headingFont); + // 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_recentPanel = new wxPanel(this); + 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_HYPERLINK, &WelcomePanel::OnOpenRecent, this, ID_WELCOME_RECENT_FIRST, - ID_WELCOME_RECENT_LAST); + Bind(wxEVT_BUTTON, &WelcomePanel::OnShowRecent, this, ID_WELCOME_RECENT); } -bool WelcomePanel::RefreshRecentFiles() +void WelcomePanel::RefreshRecentFiles() { - m_recentFiles.clear(); - m_recentPanel->DestroyChildren(); - - auto *sizer = new wxBoxSizer(wxVERTICAL); - - const auto count = std::min(static_cast(wxGetApp().GetHistoryCount()), MAX_RECENT_FILES); - for (int i = 0; i < count; i++) { - const wxString filename = wxGetApp().GetHistoryFile(i); - if (filename.empty()) { - continue; - } - - const wxFileName path(filename); - const auto id = ID_WELCOME_RECENT_FIRST + static_cast(m_recentFiles.size()); - - auto *row = new wxBoxSizer(wxHORIZONTAL); - - const int rowWidth = m_openButton->GetMinSize().GetWidth(); - const int gap = 8; - const wxClientDC dc(m_recentPanel); - const wxString name = wxControl::Ellipsize(path.GetFullName(), dc, wxELLIPSIZE_MIDDLE, - rowWidth * 2 / 3, wxELLIPSIZE_FLAGS_NONE); - - auto *link = - new wxHyperlinkCtrl(m_recentPanel, id, wxControl::EscapeMnemonics(name), wxEmptyString, - wxDefaultPosition, wxDefaultSize, wxHL_ALIGN_LEFT | wxNO_BORDER); - link->SetVisitedColour(link->GetNormalColour()); - link->SetToolTip(filename); - row->Add(link, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); - - auto *directory = new wxStaticText(m_recentPanel, wxID_ANY, wxEmptyString); - directory->SetLabelText(wxControl::Ellipsize(path.GetPath(), dc, wxELLIPSIZE_MIDDLE, - rowWidth - link->GetBestSize().GetWidth() - gap, - wxELLIPSIZE_FLAGS_NONE)); - directory->SetForegroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); - directory->SetToolTip(filename); - row->Add(directory, 0, wxALIGN_CENTER_VERTICAL); - - sizer->Add(row, 0, wxBOTTOM, 4); - m_recentFiles.push_back(filename); - } - - m_recentPanel->SetSizer(sizer); - m_recentPanel->Layout(); - - const bool hasRecentFiles = !m_recentFiles.empty(); - m_recentLine->Show(hasRecentFiles); - m_recentHeading->Show(hasRecentFiles); - m_recentPanel->Show(hasRecentFiles); - return hasRecentFiles; + const bool hasRecentFiles = wxGetApp().GetHistoryCount() > 0; + m_recentButton->Enable(hasRecentFiles); + m_recentButton->SetToolTip(hasRecentFiles ? _("Recent games") : _("No recent games")); } void WelcomePanel::LayoutControls() @@ -203,15 +414,14 @@ 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); - // Hidden, and contributing no height, when there is no file history to show. - content->Add(m_recentLine, 0, wxEXPAND | wxTOP | wxBOTTOM, 20); - content->Add(m_recentHeading, 0, wxBOTTOM, 8); - content->Add(m_recentPanel, 0, wxEXPAND); - row->AddStretchSpacer(1); row->Add(content, 0, wxEXPAND | wxLEFT | wxRIGHT, 24); row->AddStretchSpacer(1); @@ -235,26 +445,33 @@ void WelcomePanel::OnNewExtensiveForm(wxCommandEvent &) SendNewEvent(WelcomeNewProblemKind::ExtensiveForm); } -void WelcomePanel::OnOpenRecent(wxCommandEvent &p_event) +void WelcomePanel::OnShowRecent(wxCommandEvent &) { - const auto index = static_cast(p_event.GetId() - ID_WELCOME_RECENT_FIRST); - if (index < m_recentFiles.size()) { - SendOpenRecentEvent(m_recentFiles[index]); + 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::SendOpenEvent() +void WelcomePanel::OpenRecentFile(const wxString &p_filename) { - wxCommandEvent event(wxEVT_WELCOME_OPEN); + wxCommandEvent event(wxEVT_WELCOME_OPEN_RECENT); event.SetEventObject(this); + event.SetString(p_filename); GetParent()->ProcessWindowEvent(event); } -void WelcomePanel::SendOpenRecentEvent(const wxString &p_filename) +void WelcomePanel::SendOpenEvent() { - wxCommandEvent event(wxEVT_WELCOME_OPEN_RECENT); + wxCommandEvent event(wxEVT_WELCOME_OPEN); event.SetEventObject(this); - event.SetString(p_filename); GetParent()->ProcessWindowEvent(event); } @@ -271,7 +488,7 @@ void WelcomePanel::SendNewEvent(WelcomeNewProblemKind p_kind) // -------------------- WelcomeFrame::WelcomeFrame(wxWindow *parent) - : wxFrame(parent, wxID_ANY, wxT("Gambit"), wxDefaultPosition, wxDefaultSize, + : wxFrame(parent, wxID_ANY, wxT("Gambit"), wxDefaultPosition, wxSize(600, 500), wxDEFAULT_FRAME_STYLE & ~(wxRESIZE_BORDER | wxMAXIMIZE_BOX)) { CreateControls(); @@ -292,15 +509,6 @@ void WelcomeFrame::LayoutControls() auto *topSizer = new wxBoxSizer(wxVERTICAL); topSizer->Add(m_panel, 1, wxEXPAND); SetSizer(topSizer); - FitToContents(); -} - -void WelcomeFrame::FitToContents() -{ - const int contentHeight = m_panel->GetSizer()->GetMinSize().GetHeight() + 64; - m_panel->SetMinSize(wxSize(600, std::max(500, contentHeight))); - GetSizer()->SetSizeHints(this); - Layout(); } void WelcomeFrame::OnWelcomeOpen(wxCommandEvent &) @@ -351,10 +559,7 @@ bool WelcomeFrame::DoOpenRecent(const wxString &p_filename) p_filename), _("File not found"), wxOK | wxICON_EXCLAMATION, this); RemoveFromFileHistory(p_filename); - CallAfter([this]() { - m_panel->RefreshRecentFiles(); - FitToContents(); - }); + m_panel->RefreshRecentFiles(); return false; } diff --git a/src/gui/welcome.h b/src/gui/welcome.h index 31d071890b..4d91a14d0e 100644 --- a/src/gui/welcome.h +++ b/src/gui/welcome.h @@ -1,8 +1,6 @@ #ifndef GAMBIT_WELCOME_H #define GAMBIT_WELCOME_H -#include - #include #include #include @@ -11,8 +9,7 @@ class wxButton; class wxStaticText; -class wxStaticLine; -class wxBoxSizer; +class RecentFilesPopup; enum class WelcomeNewProblemKind { NormalForm, ExtensiveForm }; @@ -24,7 +21,8 @@ class WelcomePanel : public wxPanel { public: explicit WelcomePanel(wxWindow *parent); - bool RefreshRecentFiles(); + void RefreshRecentFiles(); + void OpenRecentFile(const wxString &p_filename); private: void CreateControls(); @@ -33,23 +31,20 @@ class WelcomePanel : public wxPanel { void OnOpen(wxCommandEvent &p_event); void OnNewNormalForm(wxCommandEvent &p_event); void OnNewExtensiveForm(wxCommandEvent &p_event); - void OnOpenRecent(wxCommandEvent &p_event); + void OnShowRecent(wxCommandEvent &p_event); void SendOpenEvent(); void SendNewEvent(WelcomeNewProblemKind p_kind); - void SendOpenRecentEvent(const wxString &p_filename); wxStaticBitmap *m_logoBitmap{nullptr}; wxStaticText *m_titleText{nullptr}; wxCommandLinkButton *m_openButton{nullptr}; + wxButton *m_recentButton{nullptr}; wxCommandLinkButton *m_newNormalFormButton{nullptr}; wxCommandLinkButton *m_newExtensiveFormButton{nullptr}; - wxStaticLine *m_recentLine{nullptr}; - wxStaticText *m_recentHeading{nullptr}; - wxPanel *m_recentPanel{nullptr}; - std::vector m_recentFiles; + RecentFilesPopup *m_recentPopup{nullptr}; }; class WelcomeFrame : public wxFrame { @@ -59,7 +54,6 @@ class WelcomeFrame : public wxFrame { private: void CreateControls(); void LayoutControls(); - void FitToContents(); void OnWelcomeOpen(wxCommandEvent &p_event); void OnWelcomeNew(wxCommandEvent &p_event); From 8d6cb2c0220f370bbb02d8dfcbd07fc7230481a1 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 9 Sep 2026 13:44:40 +0100 Subject: [PATCH 3/3] Keep the recent-file bookkeeping in Application --- src/gui/app.cc | 36 +++++++++++++++++++++++++++++++++--- src/gui/app.h | 12 ++++++------ src/gui/welcome.cc | 34 ++++------------------------------ 3 files changed, 43 insertions(+), 39 deletions(-) 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 428d64f9a9..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(); @@ -60,13 +64,9 @@ class Application final : public wxApp { const wxString &GetCurrentDir() { return m_currentDir; } void SetCurrentDir(const wxString &p_dir); - size_t GetHistoryCount() const { return m_fileHistory.GetCount(); } wxString GetHistoryFile(int index) const { return m_fileHistory.GetHistoryFile(index); } - void RemoveHistoryFile(int index) - { - m_fileHistory.RemoveFileFromHistory(index); - m_fileHistory.Save(*wxConfigBase::Get()); - } + //! 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 9d32f1acc1..b5267bdb48 100644 --- a/src/gui/welcome.cc +++ b/src/gui/welcome.cc @@ -1,4 +1,3 @@ - #include #include @@ -42,18 +41,6 @@ constexpr int BUTTON_HEIGHT = 72; /// The width of the dropdown part of the "Open existing game" split button. constexpr int RECENT_BUTTON_WIDTH = 36; -/// Drop `p_filename` from the application's most-recently-used list, if it is there. -void RemoveFromFileHistory(const wxString &p_filename) -{ - const auto count = static_cast(wxGetApp().GetHistoryCount()); - for (int i = 0; i < count; i++) { - if (wxGetApp().GetHistoryFile(i) == p_filename) { - wxGetApp().RemoveHistoryFile(i); - return; - } - } -} - /// Write the user's home directory as "~". wxString AbbreviateHomeDirectory(const wxString &p_path) { @@ -312,14 +299,7 @@ RecentFilesPopup::RecentFilesPopup(WelcomePanel *p_panel, int p_width) bool RecentFilesPopup::Populate() { - std::vector files; - const auto count = static_cast(wxGetApp().GetHistoryCount()); - for (int i = 0; i < count; i++) { - const wxString filename = wxGetApp().GetHistoryFile(i); - if (!filename.empty()) { - files.push_back(filename); - } - } + const auto files = wxGetApp().GetRecentFiles(); m_list->SetFiles(files); GetSizer()->Fit(this); return !files.empty(); @@ -400,7 +380,7 @@ void WelcomePanel::CreateControls() void WelcomePanel::RefreshRecentFiles() { - const bool hasRecentFiles = wxGetApp().GetHistoryCount() > 0; + const bool hasRecentFiles = !wxGetApp().GetRecentFiles().empty(); m_recentButton->Enable(hasRecentFiles); m_recentButton->SetToolTip(hasRecentFiles ? _("Recent games") : _("No recent games")); } @@ -553,18 +533,12 @@ bool WelcomeFrame::DoOpen() bool WelcomeFrame::DoOpenRecent(const wxString &p_filename) { - if (!wxFileName::FileExists(p_filename)) { - wxMessageBox(wxString::Format(_("Gambit could not find the file %s.\n\n" - "It has been removed from the list of recent games."), - p_filename), - _("File not found"), wxOK | wxICON_EXCLAMATION, this); - RemoveFromFileHistory(p_filename); + if (wxGetApp().LoadFile(p_filename, this) != AppLoadResult::Success) { m_panel->RefreshRecentFiles(); return false; } - wxGetApp().SetCurrentDir(wxPathOnly(p_filename)); - return wxGetApp().LoadFile(p_filename, this) == AppLoadResult::Success; + return true; } bool WelcomeFrame::DoCreateNew(WelcomeNewProblemKind p_kind)