diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index 32c69f6..d5f2f53 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -62,6 +62,7 @@ - Center popup dialogs on the app window (`CentreOnParent()`) so confirmations/info boxes open relative to the current app window. - Include `wxSpinCtrl` in themed input controls (Amount field) or it will keep a mismatched native background. - Do not call `applyNativeClassTheme(..., "DarkMode_Explorer", "Explorer")` for `wxTextCtrl`; on some Windows builds this causes black typed text in dark mode. Keep text inputs palette-driven (`SetThemeEnabled(false)` in dark/high-contrast as needed). + - The collection **filter** boxes use `wxTE_RICH2` so typed text can take palette colours on MSW. Do **not** call `SetHint()` on those controls: RichEdit has no cue banner, and wx's fallback writes the hint into `GetValue()`. Use `installTextCtrlPlaceholder` (paints the cue only while empty). - Text inputs are hardened in `Theme.cpp` via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme`: opt the EDIT HWND out of immersive dark mode, clear its visual style, and subclass the **parent** to answer `WM_CTLCOLOREDIT` (that message goes to the parent, not the frame — an earlier frame-level handler never ran for the toolbar filter). - Keep toolbar button behavior stable under dark/high-contrast: avoid changes that break click/tooltip affordances while experimenting with hover contrast fixes. - For dark/high-contrast button readability, do not trust native hover/pressed rendering on Windows; custom state painting in `Theme.cpp` is allowed when native visuals ignore configured colors. diff --git a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp index 4cddd53..ab944a2 100644 --- a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp +++ b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -125,7 +126,9 @@ public: auto ids = selectedIds(); std::optional> keepIds; if (!ids.empty()) keepIds = std::move(ids); + suppressListFocus_ = true; rebuildRows(keepIds); + suppressListFocus_ = false; } void applyTheme(const ThemePalette& palette) { @@ -189,7 +192,8 @@ public: wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED); list_->EnsureVisible(row); - list_->SetFocus(); + if (dynamic_cast(wxWindow::FindFocus()) == nullptr) + list_->SetFocus(); } // Move the selection by `delta` rows (+1 / -1). Used when Up/Down are @@ -750,7 +754,13 @@ private: if (inRebuild_) return; // Row click / native arrow keys: keep HWND focus on the list. Filter // nudge sets suppressListFocus_ so the caret stays in the text box. - if (!suppressListFocus_ && list_ != nullptr) list_->SetFocus(); + // Also skip the focus grab when any wxTextCtrl already has focus + // (covers the deferred CallAfter select that fires after setFilter + // has reset suppressListFocus_). + if (!suppressListFocus_ && list_ != nullptr) { + if (dynamic_cast(wxWindow::FindFocus()) == nullptr) + list_->SetFocus(); + } notifySelectionChanged(); } diff --git a/ui_wx/include/ccm/ui/Theme.hpp b/ui_wx/include/ccm/ui/Theme.hpp index 2616db1..38d9540 100644 --- a/ui_wx/include/ccm/ui/Theme.hpp +++ b/ui_wx/include/ccm/ui/Theme.hpp @@ -31,6 +31,9 @@ Theme inferThemeFromWindow(const wxWindow* window); void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme); // Force palette colors onto a text input (incl. MSW dark-mode typed-text fix). void applyPaletteToTextCtrl(wxTextCtrl* text, const ThemePalette& palette, Theme theme); +// Empty-state cue that is never part of GetValue(). Required for wxTE_RICH2 on +// MSW: SetHint() has no cue-banner there and wx writes the string as real text. +void installTextCtrlPlaceholder(wxTextCtrl* text, const wxString& hint); void themeModalDialog(wxDialog* dlg, Theme theme); int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style); int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption); diff --git a/ui_wx/src/DigiBattle99GameView.cpp b/ui_wx/src/DigiBattle99GameView.cpp index a8537b5..db745f6 100644 --- a/ui_wx/src/DigiBattle99GameView.cpp +++ b/ui_wx/src/DigiBattle99GameView.cpp @@ -107,8 +107,8 @@ void DigiBattle99GameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->AddStretchSpacer(1); - filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1)); - filterInput_->SetHint(kDigiFilterHint); + filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1), wxTE_RICH2); + installTextCtrlPlaceholder(filterInput_, kDigiFilterHint); toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4); pageSizer->Add(toolbar, 0, wxEXPAND); @@ -509,7 +509,6 @@ void DigiBattle99GameView::setFilter(std::string_view filter) { if (filterInput_->GetValue() != wanted) { filterInput_->ChangeValue(wanted); if (filter.empty()) { - filterInput_->SetHint(kDigiFilterHint); filterInput_->Refresh(); } } diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index dd9b233..d383b15 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -155,8 +155,8 @@ void MainFrame::buildLayout() { } toolbar->AddStretchSpacer(1); filterInput_ = new wxTextCtrl(toolbarPanel_, wxID_ANY, "", wxDefaultPosition, - wxSize(260, -1)); - filterInput_->SetHint(kFilterInputHint); + wxSize(260, -1), wxTE_RICH2); + installTextCtrlPlaceholder(filterInput_, kFilterInputHint); toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4); toolbarPanel_->SetSizer(toolbar); root->Add(toolbarPanel_, 0, wxEXPAND); @@ -295,7 +295,6 @@ void MainFrame::switchGame(Game g) { if (auto* view = activeView()) { if (filterInput_ != nullptr) { filterInput_->ChangeValue(wxString{}); - filterInput_->SetHint(kFilterInputHint); filterInput_->Refresh(); } view->setFilter(""); diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index 7b57717..e2bcf84 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -115,8 +115,8 @@ void PokemonGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* page toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->AddStretchSpacer(1); - filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1)); - filterInput_->SetHint(kPokeFilterHint); + filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1), wxTE_RICH2); + installTextCtrlPlaceholder(filterInput_, kPokeFilterHint); toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4); pageSizer->Add(toolbar, 0, wxEXPAND); @@ -592,7 +592,6 @@ void PokemonGameView::setFilter(std::string_view filter) { if (filterInput_->GetValue() != wanted) { filterInput_->ChangeValue(wanted); if (filter.empty()) { - filterInput_->SetHint(kPokeFilterHint); filterInput_->Refresh(); } } diff --git a/ui_wx/src/Theme.cpp b/ui_wx/src/Theme.cpp index b0cf70f..8e5d7ca 100644 --- a/ui_wx/src/Theme.cpp +++ b/ui_wx/src/Theme.cpp @@ -22,6 +22,8 @@ #include #include +#include +#include #include #include @@ -229,7 +231,7 @@ std::unordered_set gPaletteTextCtrlDestroyBound; LRESULT CALLBACK editColorParentSubclass(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, UINT_PTR /*subclassId*/, DWORD_PTR /*refData*/) { - if (msg == WM_CTLCOLOREDIT) { + if (msg == WM_CTLCOLOREDIT || msg == WM_CTLCOLORSTATIC) { const HWND editHwnd = reinterpret_cast(lParam); const auto it = gPaletteTextCtrls.find(editHwnd); if (it != gPaletteTextCtrls.end() && it->second != nullptr) { @@ -251,10 +253,22 @@ LRESULT CALLBACK editColorParentSubclass(HWND hwnd, UINT msg, WPARAM wParam, LPA return ::DefSubclassProc(hwnd, msg, wParam, lParam); } -void ensureEditColorParentSubclass(wxTextCtrl* text) { - if (text == nullptr) return; - const HWND editHwnd = reinterpret_cast(text->GetHandle()); - if (editHwnd == nullptr) return; +HWND resolveNativeEditHwnd(wxTextCtrl* text) { + if (text == nullptr) return nullptr; + const HWND wxHwnd = reinterpret_cast(text->GetHandle()); + if (wxHwnd == nullptr) return nullptr; + static const wchar_t* kClasses[] = { + L"Edit", L"RICHEDIT50W", L"RichEdit50W", L"RICHEDIT20W", L"RichEdit20W", + }; + for (const wchar_t* cls : kClasses) { + HWND child = ::FindWindowExW(wxHwnd, nullptr, cls, nullptr); + if (child != nullptr) return child; + } + return wxHwnd; +} + +void ensureEditColorParentSubclass(wxTextCtrl* text, HWND editHwnd) { + if (text == nullptr || editHwnd == nullptr) return; gPaletteTextCtrls[editHwnd] = text; if (gPaletteTextCtrlDestroyBound.insert(text).second) { text->Bind(wxEVT_DESTROY, [text, editHwnd](wxWindowDestroyEvent& event) { @@ -271,25 +285,127 @@ void ensureEditColorParentSubclass(wxTextCtrl* text) { } } +constexpr UINT_PTR kPlaceholderSubclassId = 0x43434d50; // 'CCMP' +struct PlaceholderState { + wxTextCtrl* text{nullptr}; + std::wstring hint; +}; +std::unordered_map gPlaceholders; +std::unordered_set gPlaceholderSubclassed; + +wxColour mixColours(const wxColour& a, const wxColour& b, int aParts, int total) { + const int bParts = total - aParts; + auto mix = [&](unsigned char ca, unsigned char cb) -> unsigned char { + return static_cast((static_cast(ca) * aParts + + static_cast(cb) * bParts) / + total); + }; + return wxColour(mix(a.Red(), b.Red()), mix(a.Green(), b.Green()), mix(a.Blue(), b.Blue())); +} + +void paintEmptyPlaceholder(HWND hwnd, HDC suppliedDc) { + const auto it = gPlaceholders.find(hwnd); + if (it == gPlaceholders.end() || it->second.hint.empty()) return; + if (::GetWindowTextLengthW(hwnd) > 0) return; + // Native cue banners hide as soon as the caret is in the box, even if empty. + const HWND focus = ::GetFocus(); + if (focus == hwnd) return; + if (it->second.text != nullptr && it->second.text->HasFocus()) return; + + HDC hdc = suppliedDc; + if (hdc == nullptr) hdc = ::GetDC(hwnd); + if (hdc == nullptr) return; + + RECT rc{}; + ::GetClientRect(hwnd, &rc); + rc.left += 4; + + wxColour fg(160, 160, 160); + wxColour bg(45, 45, 45); + if (it->second.text != nullptr) { + const wxColour textFg = it->second.text->GetForegroundColour(); + const wxColour textBg = it->second.text->GetBackgroundColour(); + if (textFg.IsOk()) fg = textFg; + if (textBg.IsOk()) bg = textBg; + } + const wxColour muted = mixColours(fg, bg, 2, 5); + + const HFONT source = reinterpret_cast(::SendMessageW(hwnd, WM_GETFONT, 0, 0)); + HFONT hintFont = nullptr; + if (source != nullptr) { + LOGFONTW lf{}; + if (::GetObjectW(source, sizeof(lf), &lf) != 0) { + // Cue text should read lighter than typed input. RichEdit's WM_GETFONT + // is often a heavy face; Segoe UI Light keeps the hint thin on MSW. + wcsncpy(lf.lfFaceName, L"Segoe UI Light", LF_FACESIZE - 1); + lf.lfFaceName[LF_FACESIZE - 1] = 0; + lf.lfWeight = FW_LIGHT; + lf.lfItalic = FALSE; + lf.lfQuality = CLEARTYPE_QUALITY; + if (lf.lfHeight < -1) lf.lfHeight += 1; + hintFont = ::CreateFontIndirectW(&lf); + } + } + const HFONT useFont = hintFont != nullptr ? hintFont : source; + const HGDIOBJ oldFont = (useFont != nullptr) ? ::SelectObject(hdc, useFont) : nullptr; + ::SetBkMode(hdc, TRANSPARENT); + ::SetTextColor(hdc, RGB(muted.Red(), muted.Green(), muted.Blue())); + ::DrawTextW(hdc, it->second.hint.c_str(), -1, &rc, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + if (oldFont != nullptr) ::SelectObject(hdc, oldFont); + if (hintFont != nullptr) ::DeleteObject(hintFont); + if (suppliedDc == nullptr) ::ReleaseDC(hwnd, hdc); +} + +LRESULT CALLBACK placeholderEditSubclass(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, + UINT_PTR /*subclassId*/, DWORD_PTR /*refData*/) { + if (msg == WM_PAINT || msg == WM_PRINTCLIENT) { + const LRESULT result = ::DefSubclassProc(hwnd, msg, wParam, lParam); + paintEmptyPlaceholder(hwnd, msg == WM_PRINTCLIENT ? reinterpret_cast(wParam) : nullptr); + return result; + } + if (msg == WM_SETFOCUS || msg == WM_KILLFOCUS) { + const LRESULT result = ::DefSubclassProc(hwnd, msg, wParam, lParam); + ::InvalidateRect(hwnd, nullptr, TRUE); + return result; + } + if (msg == WM_NCDESTROY) { + gPlaceholders.erase(hwnd); + gPlaceholderSubclassed.erase(hwnd); + ::RemoveWindowSubclass(hwnd, placeholderEditSubclass, kPlaceholderSubclassId); + } + return ::DefSubclassProc(hwnd, msg, wParam, lParam); +} + void hardenTextCtrlNativeTheme(wxTextCtrl* text, Theme theme) { if (text == nullptr) return; const bool darkLike = isDarkLikeTheme(theme); - text->SetThemeEnabled(!darkLike); - const HWND hwnd = reinterpret_cast(text->GetHandle()); - if (hwnd == nullptr) return; + const HWND wxHwnd = reinterpret_cast(text->GetHandle()); + if (wxHwnd == nullptr) return; - // Opt this EDIT out of immersive dark mode so typed text uses our palette. + HWND editHwnd = resolveNativeEditHwnd(text); + + // Enable native dark mode on the EDIT so its themed renderer paints + // light-on-dark. The app-wide preferred mode is already ForceDark + // (set in applyFrameTitlebarTheme); individual windows opt in here. + // DarkMode_Explorer explicitly selects the dark text variant in the + // visual-style theme — plain "Explorer" gave dark background but the + // text colour stayed dark on some Windows 10/11 builds. if (auto allowDarkModeForWindow = resolveAllowDarkModeForWindow()) { - allowDarkModeForWindow(hwnd, FALSE); + allowDarkModeForWindow(editHwnd, darkLike ? TRUE : FALSE); + if (editHwnd != wxHwnd) + allowDarkModeForWindow(wxHwnd, darkLike ? TRUE : FALSE); } - if (darkLike) { - if (auto setWindowTheme = resolveSetWindowTheme()) { - // Empty theme class disables visual-style painting of the EDIT contents. - setWindowTheme(hwnd, L"", L""); - } + if (auto setWindowTheme = resolveSetWindowTheme()) { + const wchar_t* cls = darkLike ? L"DarkMode_Explorer" : L"Explorer"; + setWindowTheme(editHwnd, cls, nullptr); + if (editHwnd != wxHwnd) + setWindowTheme(wxHwnd, cls, nullptr); } - ensureEditColorParentSubclass(text); + ::SendMessageW(editHwnd, WM_THEMECHANGED, 0, 0); + ensureEditColorParentSubclass(text, editHwnd); + ::InvalidateRect(editHwnd, nullptr, TRUE); } COLORREF toColorRef(const wxColour& c) { @@ -728,9 +844,42 @@ void applyPaletteToTextCtrl(wxTextCtrl* text, const ThemePalette& palette, Theme text->SetForegroundColour(palette.inputText); text->SetOwnBackgroundColour(palette.inputBg); text->SetOwnForegroundColour(palette.inputText); + // For RichEdit-backed controls (wxTE_RICH2) the character format must be + // set explicitly — SetForegroundColour alone does not propagate to the + // native EM_SETCHARFORMAT on all wx builds. + wxTextAttr attr; + attr.SetTextColour(palette.inputText); + text->SetDefaultStyle(attr); + if (text->GetLastPosition() > 0) + text->SetStyle(0, text->GetLastPosition(), attr); text->Refresh(); } +void installTextCtrlPlaceholder(wxTextCtrl* text, const wxString& hint) { + if (text == nullptr) return; +#ifdef __WXMSW__ + // RichEdit (wxTE_RICH2) ignores EM_SETCUEBANNER; wx's SetHint fallback + // writes the cue into GetValue(). Paint the hint ourselves when empty. + const HWND editHwnd = resolveNativeEditHwnd(text); + if (editHwnd == nullptr) { + text->CallAfter([text, hint]() { installTextCtrlPlaceholder(text, hint); }); + return; + } + gPlaceholders[editHwnd] = PlaceholderState{text, hint.ToStdWstring()}; + if (gPlaceholderSubclassed.insert(editHwnd).second) { + if (::SetWindowSubclass(editHwnd, placeholderEditSubclass, kPlaceholderSubclassId, 0) == + FALSE) { + gPlaceholderSubclassed.erase(editHwnd); + gPlaceholders.erase(editHwnd); + return; + } + } + ::InvalidateRect(editHwnd, nullptr, TRUE); +#else + text->SetHint(hint); +#endif +} + void themeModalDialog(wxDialog* dlg, Theme theme) { if (dlg == nullptr) return; const ThemePalette palette = paletteForTheme(theme); diff --git a/ui_wx/src/YuGiOhBandaiGameView.cpp b/ui_wx/src/YuGiOhBandaiGameView.cpp index e806120..abbdbf1 100644 --- a/ui_wx/src/YuGiOhBandaiGameView.cpp +++ b/ui_wx/src/YuGiOhBandaiGameView.cpp @@ -159,8 +159,8 @@ void YuGiOhBandaiGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->AddStretchSpacer(1); - filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1)); - filterInput_->SetHint(kBandaiFilterHint); + filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1), wxTE_RICH2); + installTextCtrlPlaceholder(filterInput_, kBandaiFilterHint); toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4); pageSizer->Add(toolbar, 0, wxEXPAND); @@ -537,7 +537,6 @@ void YuGiOhBandaiGameView::setFilter(std::string_view filter) { if (filterInput_->GetValue() != wanted) { filterInput_->ChangeValue(wanted); if (filter.empty()) { - filterInput_->SetHint(kBandaiFilterHint); filterInput_->Refresh(); } } diff --git a/ui_wx/src/YuGiOhGameView.cpp b/ui_wx/src/YuGiOhGameView.cpp index 39a092f..241c2d6 100644 --- a/ui_wx/src/YuGiOhGameView.cpp +++ b/ui_wx/src/YuGiOhGameView.cpp @@ -113,8 +113,8 @@ void YuGiOhGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageS toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->AddStretchSpacer(1); - filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1)); - filterInput_->SetHint(kYgoFilterHint); + filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1), wxTE_RICH2); + installTextCtrlPlaceholder(filterInput_, kYgoFilterHint); toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4); pageSizer->Add(toolbar, 0, wxEXPAND); @@ -515,7 +515,6 @@ void YuGiOhGameView::setFilter(std::string_view filter) { if (filterInput_->GetValue() != wanted) { filterInput_->ChangeValue(wanted); if (filter.empty()) { - filterInput_->SetHint(kYgoFilterHint); filterInput_->Refresh(); } }