diff --git a/common/dialogs/html_message_box.cpp b/common/dialogs/html_message_box.cpp
index 9a4c2d9cb0..90d8157955 100644
--- a/common/dialogs/html_message_box.cpp
+++ b/common/dialogs/html_message_box.cpp
@@ -26,6 +26,11 @@
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
#include
#include
#include
@@ -38,6 +43,33 @@ HTML_MESSAGE_BOX::HTML_MESSAGE_BOX( wxWindow* aParent, const wxString& aTitle,
m_htmlWindow->SetLayoutDirection( wxLayout_LeftToRight );
ListClear();
+ m_searchPanel = new wxPanel( this );
+ wxBoxSizer* searchSizer = new wxBoxSizer( wxHORIZONTAL );
+ m_matchCount = new wxStaticText( m_searchPanel, wxID_ANY, wxEmptyString );
+ m_searchCtrl = new wxTextCtrl( m_searchPanel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxTE_PROCESS_ENTER );
+ m_prevBtn = new wxButton( m_searchPanel, wxID_ANY, wxS( "∧" ), wxDefaultPosition, wxDefaultSize, wxBORDER_NONE );
+ m_nextBtn = new wxButton( m_searchPanel, wxID_ANY, wxS( "∨" ), wxDefaultPosition, wxDefaultSize, wxBORDER_NONE );
+
+ // Set minimum size for buttons to make them thinner
+ m_prevBtn->SetMinSize( wxSize( 25, -1 ) );
+ m_nextBtn->SetMinSize( wxSize( 25, -1 ) );
+
+ // Set minimum width for match count to ensure it's visible
+ m_matchCount->SetMinSize( wxSize( 60, -1 ) );
+
+ searchSizer->Add( m_matchCount, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 );
+ searchSizer->Add( m_searchCtrl, 1, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 );
+ searchSizer->Add( m_prevBtn, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 2 );
+ searchSizer->Add( m_nextBtn, 0, wxALIGN_CENTER_VERTICAL );
+ m_searchPanel->SetSizer( searchSizer );
+ m_searchPanel->Hide();
+ GetSizer()->Insert( 0, m_searchPanel, 0, wxALIGN_RIGHT|wxTOP|wxRIGHT, 5 );
+
+ m_searchCtrl->Bind( wxEVT_TEXT, &HTML_MESSAGE_BOX::OnSearchText, this );
+ m_searchCtrl->Bind( wxEVT_TEXT_ENTER, &HTML_MESSAGE_BOX::OnNext, this );
+ m_prevBtn->Bind( wxEVT_BUTTON, &HTML_MESSAGE_BOX::OnPrev, this );
+ m_nextBtn->Bind( wxEVT_BUTTON, &HTML_MESSAGE_BOX::OnNext, this );
+
// Gives a default logical size (the actual size depends on the display definition)
if( aSize != wxDefaultSize )
setSizeInDU( aSize.x, aSize.y );
@@ -162,7 +194,46 @@ void HTML_MESSAGE_BOX::OnHTMLLinkClicked( wxHtmlLinkEvent& event )
void HTML_MESSAGE_BOX::OnCharHook( wxKeyEvent& aEvent )
{
- // shift-return (Mac default) or Ctrl-Return (GTK) for OK
+ if( m_searchPanel->IsShown() )
+ {
+ if( aEvent.GetKeyCode() == WXK_ESCAPE )
+ {
+ HideSearchBar();
+ return;
+ }
+ else if( aEvent.GetKeyCode() == WXK_RETURN || aEvent.GetKeyCode() == WXK_NUMPAD_ENTER )
+ {
+ wxCommandEvent evt;
+ OnNext( evt );
+ return;
+ }
+ else if( aEvent.GetKeyCode() == WXK_BACK )
+ {
+ long from, to;
+ m_searchCtrl->GetSelection( &from, &to );
+
+ if( from == to )
+ m_searchCtrl->Remove( std::max( 0L, from - 1 ), from );
+ else
+ m_searchCtrl->Remove( from, to );
+
+ return;
+ }
+ else if( !aEvent.HasModifiers() && wxIsprint( aEvent.GetUnicodeKey() ) )
+ {
+ m_searchCtrl->AppendText( wxString( (wxChar) aEvent.GetUnicodeKey() ) );
+ return;
+ }
+ }
+ else if( !aEvent.HasModifiers() && wxIsprint( aEvent.GetUnicodeKey() ) )
+ {
+ ShowSearchBar();
+ m_searchCtrl->SetValue( wxString( (wxChar) aEvent.GetUnicodeKey() ) );
+ m_currentMatch = 0;
+ updateSearch();
+ return;
+ }
+
if( aEvent.GetKeyCode() == WXK_ESCAPE )
{
wxPostEvent( this, wxCommandEvent( wxEVT_COMMAND_BUTTON_CLICKED, wxID_OK ) );
@@ -189,3 +260,195 @@ void HTML_MESSAGE_BOX::OnCharHook( wxKeyEvent& aEvent )
aEvent.Skip();
}
+
+void HTML_MESSAGE_BOX::OnSearchText( wxCommandEvent& aEvent )
+{
+ m_currentMatch = 0;
+ updateSearch();
+}
+
+void HTML_MESSAGE_BOX::OnNext( wxCommandEvent& aEvent )
+{
+ if( !m_matchPos.empty() )
+ {
+ m_currentMatch = ( m_currentMatch + 1 ) % m_matchPos.size();
+ updateSearch();
+ }
+}
+
+void HTML_MESSAGE_BOX::OnPrev( wxCommandEvent& aEvent )
+{
+ if( !m_matchPos.empty() )
+ {
+ m_currentMatch = ( m_currentMatch + m_matchPos.size() - 1 ) % m_matchPos.size();
+ updateSearch();
+ }
+}
+
+void HTML_MESSAGE_BOX::ShowSearchBar()
+{
+ if( !m_searchPanel->IsShown() )
+ {
+ m_originalSource = m_source;
+ m_searchPanel->Show();
+ Layout();
+ m_searchCtrl->SetFocus();
+ }
+}
+
+void HTML_MESSAGE_BOX::HideSearchBar()
+{
+ if( m_searchPanel->IsShown() )
+ {
+ m_searchPanel->Hide();
+ Layout();
+ m_source = m_originalSource;
+ reload();
+
+ // Refocus on the main HTML window so user can press Escape again to close the dialog
+ m_htmlWindow->SetFocus();
+ }
+}
+
+void HTML_MESSAGE_BOX::updateSearch()
+{
+ wxString term = m_searchCtrl->GetValue();
+
+ if( term.IsEmpty() )
+ {
+ m_source = m_originalSource;
+ reload();
+ m_matchPos.clear();
+ m_matchCount->SetLabel( wxEmptyString );
+ return;
+ }
+
+ m_matchPos.clear();
+
+ // Search only in text content, not in HTML tags
+ wxString termLower = term.Lower();
+ size_t pos = 0;
+ bool insideTag = false;
+
+ while( pos < m_originalSource.length() )
+ {
+ wxChar ch = m_originalSource[pos];
+
+ if( ch == '<' )
+ {
+ insideTag = true;
+ }
+ else if( ch == '>' )
+ {
+ insideTag = false;
+ pos++;
+ continue;
+ }
+
+ // Only search for matches when we're not inside an HTML tag
+ if( !insideTag )
+ {
+ // Check if we have a match starting at this position
+ if( pos + termLower.length() <= m_originalSource.length() )
+ {
+ wxString candidate = m_originalSource.Mid( pos, termLower.length() ).Lower();
+ if( candidate == termLower )
+ {
+ // Verify that this match doesn't span into an HTML tag
+ bool validMatch = true;
+ for( size_t i = 0; i < termLower.length(); i++ )
+ {
+ if( pos + i < m_originalSource.length() && m_originalSource[pos + i] == '<' )
+ {
+ validMatch = false;
+ break;
+ }
+ }
+
+ if( validMatch )
+ {
+ m_matchPos.push_back( pos );
+ }
+ }
+ }
+ }
+
+ pos++;
+ }
+
+ if( m_matchPos.empty() )
+ {
+ m_source = m_originalSource;
+ reload();
+ m_matchCount->SetLabel( wxS( "0/0" ) );
+ return;
+ }
+
+ if( m_currentMatch >= (int) m_matchPos.size() )
+ m_currentMatch = 0;
+
+ wxString out;
+ size_t start = 0;
+
+ for( size_t i = 0; i < m_matchPos.size(); ++i )
+ {
+ size_t idx = m_matchPos[i];
+ out += m_originalSource.Mid( start, idx - start );
+ wxString matchStr = m_originalSource.Mid( idx, term.length() );
+
+ // HTML-escape the match string to prevent HTML parsing issues
+ wxString escapedMatchStr = matchStr;
+ escapedMatchStr.Replace( wxS( "&" ), wxS( "&" ) );
+ escapedMatchStr.Replace( wxS( "<" ), wxS( "<" ) );
+ escapedMatchStr.Replace( wxS( ">" ), wxS( ">" ) );
+ escapedMatchStr.Replace( wxS( "\"" ), wxS( """ ) );
+
+ if( (int) i == m_currentMatch )
+ {
+ // Use a unique anchor name for each search to avoid conflicts
+ wxString anchorName = wxString::Format( wxS( "kicad_search_%d" ), m_currentMatch );
+ out += wxString::Format( wxS( "%s" ),
+ anchorName, escapedMatchStr );
+ }
+ else
+ {
+ out += wxString::Format( wxS( "%s" ), escapedMatchStr );
+ }
+
+ start = idx + term.length();
+ }
+
+ out += m_originalSource.Mid( start );
+ m_source = out;
+ reload();
+
+ // Use CallAfter to ensure the HTML is fully loaded before scrolling
+ // Only scroll if we have matches and a valid current match
+ if( !m_matchPos.empty() && m_currentMatch >= 0 && m_currentMatch < (int)m_matchPos.size() )
+ {
+ CallAfter( [this]()
+ {
+ // Try to scroll to the anchor, with fallback if it fails
+ wxString anchorName = wxString::Format( wxS( "kicad_search_%d" ), m_currentMatch );
+ if( !m_htmlWindow->ScrollToAnchor( anchorName ) )
+ {
+ // If anchor scrolling fails, try to scroll to approximate position
+ // Calculate approximate scroll position based on match location
+ if( !m_matchPos.empty() && m_currentMatch < (int)m_matchPos.size() )
+ {
+ size_t matchPos = m_matchPos[m_currentMatch];
+ size_t totalLength = m_originalSource.length();
+ if( totalLength > 0 )
+ {
+ // Scroll to approximate percentage of document
+ double ratio = (double)matchPos / (double)totalLength;
+ int scrollPos = (int)(ratio * m_htmlWindow->GetScrollRange( wxVERTICAL ));
+ m_htmlWindow->Scroll( 0, scrollPos );
+ }
+ }
+ }
+ } );
+ }
+
+ m_matchCount->SetLabel( wxString::Format( wxS( "%d/%zu" ), m_currentMatch + 1, m_matchPos.size() ) );
+}
diff --git a/common/widgets/html_window.cpp b/common/widgets/html_window.cpp
index 5d0d914948..547b6c55f7 100644
--- a/common/widgets/html_window.cpp
+++ b/common/widgets/html_window.cpp
@@ -78,6 +78,18 @@ void HTML_WINDOW::ThemeChanged()
}
+bool HTML_WINDOW::ScrollToAnchor( const wxString& aAnchor )
+{
+ // Check if we have content loaded
+ if( !GetInternalRepresentation() )
+ return false;
+
+ // Try to scroll to the anchor
+ bool result = wxHtmlWindow::ScrollToAnchor( aAnchor );
+ return result;
+}
+
+
void HTML_WINDOW::onThemeChanged( wxSysColourChangedEvent &aEvent )
{
ThemeChanged();
diff --git a/include/dialogs/html_message_box.h b/include/dialogs/html_message_box.h
index 10976ae1e0..af60b60a62 100644
--- a/include/dialogs/html_message_box.h
+++ b/include/dialogs/html_message_box.h
@@ -26,6 +26,12 @@
#define HTML_MESSAGE_BOX_H
#include
+#include
+
+class wxPanel;
+class wxStaticText;
+class wxTextCtrl;
+class wxButton;
class HTML_MESSAGE_BOX : public DIALOG_DISPLAY_HTML_TEXT_BASE
@@ -91,9 +97,23 @@ protected:
void onThemeChanged( wxSysColourChangedEvent &aEvent );
virtual void OnCharHook( wxKeyEvent& aEvt ) override;
+ void OnSearchText( wxCommandEvent& aEvent );
+ void OnNext( wxCommandEvent& aEvent );
+ void OnPrev( wxCommandEvent& aEvent );
+ void ShowSearchBar();
+ void HideSearchBar();
+ void updateSearch();
private:
wxString m_source;
+ wxString m_originalSource;
+ wxPanel* m_searchPanel;
+ wxStaticText* m_matchCount;
+ wxTextCtrl* m_searchCtrl;
+ wxButton* m_prevBtn;
+ wxButton* m_nextBtn;
+ std::vector m_matchPos;
+ int m_currentMatch;
};
#endif // HTML_MESSAGE_BOX_H
diff --git a/include/widgets/html_window.h b/include/widgets/html_window.h
index 2086e2f980..c44e3d1ac8 100644
--- a/include/widgets/html_window.h
+++ b/include/widgets/html_window.h
@@ -48,6 +48,11 @@ public:
*/
void ThemeChanged();
+ /*
+ * Scroll to an anchor in the HTML content.
+ */
+ bool ScrollToAnchor( const wxString& aAnchor );
+
private:
void onThemeChanged( wxSysColourChangedEvent& aEvent );
void onRightClick( wxMouseEvent& event );