Salome HOME
updated copyright message
[modules/gui.git] / tools / PyEditor / src / PyEditor_FindTool.cxx
1 // Copyright (C) 2015-2023  OPEN CASCADE
2 //
3 // This library is free software; you can redistribute it and/or
4 // modify it under the terms of the GNU Lesser General Public
5 // License as published by the Free Software Foundation; either
6 // version 2.1 of the License, or (at your option) any later version.
7 //
8 // This library is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
11 // Lesser General Public License for more details.
12 //
13 // You should have received a copy of the GNU Lesser General Public
14 // License along with this library; if not, write to the Free Software
15 // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307 USA
16 //
17 // See http://www.salome-platform.org/ or email : webmaster.salome@opencascade.com
18 //
19 // File   : PyEditor_FindTool.cxx
20 // Author : Vadim SANDLER, Open CASCADE S.A.S. (vadim.sandler@opencascade.com)
21 //
22
23 #include "PyEditor_FindTool.h"
24 #include "PyEditor_Editor.h"
25
26 #include <QAction>
27 #include <QCompleter>
28 #include <QEvent>
29 #include <QGridLayout>
30 #include <QIcon>
31 #include <QLabel>
32 #include <QLineEdit>
33 #include <QMenu>
34 #include <QSignalMapper>
35 #include <QToolButton>
36
37 /*!
38   \class PyEditor_FindTool
39   \brief Find / Replace widget for PyEditor
40 */
41
42 /*!
43   \brief Constructor.
44   \param editor Python editor widget.
45   \param parent Parent widget.
46 */
47 PyEditor_FindTool::PyEditor_FindTool( PyEditor_Editor* editor, QWidget* parent )
48   : QWidget( parent ), myEditor( editor )
49 {
50   QLabel* findLabel = new QLabel( tr( "FIND_LABEL" ), this );
51   myFindEdit = new QLineEdit( this );
52   myFindEdit->setClearButtonEnabled( true );
53   myFindEdit->installEventFilter( this );
54   connect( myFindEdit, SIGNAL( textChanged( const QString& ) ), this, SLOT( find( const QString& ) ) );
55   connect( myFindEdit, SIGNAL( returnPressed() ), this, SLOT( findNext() ) );
56   myFindEdit->setCompleter( new QCompleter( myFindEdit ) );
57   myFindEdit->completer()->setModel( &myFindCompletion );
58
59   QLabel* replaceLabel = new QLabel( tr( "REPLACE_LABEL" ), this );
60   myReplaceEdit = new QLineEdit( this );
61   myReplaceEdit->setClearButtonEnabled( true );
62   myReplaceEdit->installEventFilter( this );
63   myReplaceEdit->setCompleter( new QCompleter( myReplaceEdit ) );
64   myReplaceEdit->completer()->setModel( &myReplaceCompletion );
65
66   myInfoLabel = new QLabel( this );
67   myInfoLabel->setAlignment( Qt::AlignVCenter | Qt::AlignRight );
68
69   QToolButton* prevBtn = new QToolButton( this );
70   prevBtn->setIcon( QIcon( ":images/py_find_previous.png" ) );
71   prevBtn->setAutoRaise( true );
72   connect( prevBtn, SIGNAL( clicked() ), this, SLOT( findPrevious() ) );
73
74   QToolButton* nextBtn = new QToolButton( this );
75   nextBtn->setIcon( QIcon( ":images/py_find_next.png" ) );
76   nextBtn->setAutoRaise( true );
77   connect( nextBtn, SIGNAL( clicked() ), this, SLOT( findNext() ) );
78
79   QToolButton* replaceBtn = new QToolButton();
80   replaceBtn->setText( tr( "REPLACE_BTN" ) );
81   replaceBtn->setAutoRaise( true );
82   connect( replaceBtn, SIGNAL( clicked() ), this, SLOT( replace() ) );
83
84   QToolButton* replaceAllBtn = new QToolButton();
85   replaceAllBtn->setText( tr( "REPLACE_ALL_BTN" ) );
86   replaceAllBtn->setAutoRaise( true );
87   connect( replaceAllBtn, SIGNAL( clicked() ), this, SLOT( replaceAll() ) );
88
89   QHBoxLayout* hl = new QHBoxLayout;
90   hl->setContentsMargins( 0, 0, 0, 0 );
91   hl->setSpacing( 0 );
92   hl->addWidget( prevBtn );
93   hl->addWidget( nextBtn );
94
95   QGridLayout* l = new QGridLayout( this );
96   l->setContentsMargins( 6, 2, 6, 2 );
97   l->setSpacing( 2 );
98   l->addWidget( findLabel, 0, 0 );
99   l->addWidget( myFindEdit, 0, 1 );
100   l->addLayout( hl, 0, 2 );
101   l->addWidget( myInfoLabel, 0, 3 );
102   l->addWidget( replaceLabel, 1, 0 );
103   l->addWidget( myReplaceEdit, 1, 1 );
104   l->addWidget( replaceBtn, 1, 2 );
105   l->addWidget( replaceAllBtn, 1, 3 );
106
107   QAction* menuAction = myFindEdit->addAction( QIcon(":images/py_search.png"), QLineEdit::LeadingPosition );
108   connect( menuAction, SIGNAL( triggered( bool ) ), this, SLOT( showMenu() ) );
109
110   addAction( new QAction( tr( "CASE_SENSITIVE_CHECK" ), this ) );
111   addAction( new QAction( tr( "WHOLE_WORDS_CHECK" ), this ) );
112   addAction( new QAction( tr( "REGEX_CHECK" ), this ) );
113   addAction( new QAction( QIcon( ":/images/py_find.png" ), tr( "Find" ), this ) );
114   addAction( new QAction( tr( "FindPrevious" ), this ) );
115   addAction( new QAction( tr( "FindNext" ), this ) );
116   addAction( new QAction( QIcon( ":/images/py_replace.png" ), tr( "Replace" ), this ) );
117
118   foreach ( QAction* action, actions().mid( CaseSensitive, RegExp+1 ) )
119   {
120     action->setCheckable( true );
121     connect( action, SIGNAL( toggled( bool ) ), this, SLOT( update() ) );
122   }
123
124   QSignalMapper* mapper = new QSignalMapper( this );
125   connect( mapper, SIGNAL( mapped( int ) ), this, SLOT( activate( int ) ) );
126
127   for ( int i = Find; i < actions().count(); i++ )
128   {
129     QAction* action = actions()[i];
130     action->setShortcuts( shortcuts( i ) );
131     action->setShortcutContext( Qt::WidgetWithChildrenShortcut );
132     connect( action, SIGNAL( triggered( bool ) ), mapper, SLOT( map() ) );
133     mapper->setMapping( action, i );
134     myEditor->addAction( action );
135   }
136
137   myEditor->installEventFilter( this );
138   connect( myEditor, SIGNAL( customizeMenu( QMenu* ) ), this, SLOT( customizeMenu( QMenu* ) ) );
139
140   hide();
141 }
142
143 /*!
144   \brief Process events for this widget,
145   \param e Event being processed.
146   \return true if event's processing should be stopped; false otherwise.
147 */
148 bool PyEditor_FindTool::event( QEvent* e )
149 {
150   if ( e->type() == QEvent::EnabledChange )
151   {
152     updateShortcuts();
153   }
154   else if ( e->type() == QEvent::KeyPress )
155   {
156     QKeyEvent* ke = (QKeyEvent*)e;
157     switch ( ke->key() )
158     {
159     case Qt::Key_Escape:
160       hide();
161       return true;
162     default:
163       break;
164     }
165   }
166   else if ( e->type() == QEvent::Hide )
167   {
168     addCompletion( myFindEdit->text(), false );
169     addCompletion( myReplaceEdit->text(), true );
170     myEditor->setFocus();
171   }
172   return QWidget::event( e );
173 }
174
175 /*!
176   \brief Filter events from watched objects.
177   \param o Object being watched.
178   \param e Event being processed.
179   \return true if event should be filtered out; false otherwise.
180 */
181 bool PyEditor_FindTool::eventFilter( QObject* o, QEvent* e )
182 {
183   if ( o == myFindEdit )
184   {
185     if ( e->type() == QEvent::KeyPress )
186     {
187       QKeyEvent* keyEvent = (QKeyEvent*)e;
188       if ( keyEvent->key() == Qt::Key_Escape && !myFindEdit->text().isEmpty() )
189       {
190         addCompletion( myFindEdit->text(), false );
191         myFindEdit->clear();
192         return true;
193       }
194     }
195   }
196   else if ( o == myReplaceEdit )
197   {
198     if ( e->type() == QEvent::KeyPress )
199     {
200       QKeyEvent* keyEvent = (QKeyEvent*)e;
201       if ( keyEvent->key() == Qt::Key_Escape && !myReplaceEdit->text().isEmpty() )
202       {
203         myReplaceEdit->clear();
204         return true;
205       }
206     }
207   }
208   else if ( o == myEditor )
209   {
210     if ( e->type() == QEvent::EnabledChange )
211     {
212       setEnabled( myEditor->isEnabled() );
213     }
214     else if ( e->type() == QEvent::Hide )
215     {
216       hide();
217     }
218     else if ( e->type() == QEvent::KeyPress )
219     {
220       QKeyEvent* ke = (QKeyEvent*)e;
221       switch ( ke->key() )
222       {
223       case Qt::Key_Escape:
224         if ( isVisible() )
225         {
226           hide();
227           return true;
228         }
229         break;
230       default:
231         break;
232       }
233     }
234   }
235   return QWidget::eventFilter( o, e );
236 }
237
238 /*!
239   \brief Slot: activate 'Find' dialog.
240 */
241 void PyEditor_FindTool::activateFind()
242 {
243   activate( Find );
244 }
245
246 /*!
247   \brief Customize menu for editor.
248 */
249 void PyEditor_FindTool::customizeMenu( QMenu* menu )
250 {
251   menu->addSeparator();
252   menu->addAction( actions()[Find] );
253   menu->addAction( actions()[Replace] );
254 }
255
256 /*!
257   \brief Slot: activate 'Replace' dialog.
258 */
259 void PyEditor_FindTool::activateReplace()
260 {
261   activate( Replace );
262 }
263
264 /*!
265   \brief Slot: show context menu with search options.
266   \internal
267 */
268 void PyEditor_FindTool::showMenu()
269 {
270   QMenu::exec( actions().mid( CaseSensitive, RegExp+1 ), QCursor::pos() );
271 }
272  
273 /*!
274   \brief Slot: find text being typed in the 'Find' control.
275   \param text Text entered by the user.
276   \internal
277 */
278 void PyEditor_FindTool::find( const QString& text )
279 {
280   find( text, 0 );
281 }
282
283 /*!
284   \brief Slot: find text entered in the 'Find' control.
285   \internal
286   \overload
287 */
288 void PyEditor_FindTool::find()
289 {
290   find( myFindEdit->text(), 0 );
291 }
292
293 /*!
294   \brief Slot: find previous matched item; called when user presses 'Previous' button.
295   \internal
296 */
297 void PyEditor_FindTool::findPrevious()
298 {
299   find( myFindEdit->text(), -1 );
300 }
301
302 /*!
303   \brief Slot: find next matched item; called when user presses 'Next' button.
304   \internal
305 */
306 void PyEditor_FindTool::findNext()
307 {
308   find( myFindEdit->text(), 1 );
309 }
310
311 /*!
312   \brief Slot: replace currently selected match; called when user presses 'Replace' button.
313   \internal
314 */
315 void PyEditor_FindTool::replace()
316 {
317   QString text = myFindEdit->text();
318   QString replacement = myReplaceEdit->text();
319
320   QTextCursor editor = myEditor->textCursor();
321   if ( editor.hasSelection() && editor.selectedText() == text )
322   {
323     editor.beginEditBlock();
324     editor.removeSelectedText();
325     editor.insertText( replacement );
326     editor.endEditBlock();
327     find();
328   }
329 }
330
331 /*!
332   \brief Slot: replace all matches; called when user presses 'Replace All' button.
333   \internal
334 */
335 void PyEditor_FindTool::replaceAll()
336 {
337   QString text = myFindEdit->text();
338   QString replacement = myReplaceEdit->text();
339   QList<QTextCursor> results = matches( text );
340   if ( !results.isEmpty() )
341   {
342     QTextCursor editor( myEditor->document() );
343     editor.beginEditBlock();
344     foreach ( QTextCursor cursor, results )
345     {
346       editor.setPosition( cursor.anchor() );
347       editor.setPosition( cursor.position(), QTextCursor::KeepAnchor );
348       editor.removeSelectedText();
349       editor.insertText( replacement );
350     }
351     editor.endEditBlock();
352     find();
353   }
354 }
355
356 /*!
357   \brief Slot: restart search; called when search options are changed.
358   \internal
359 */
360 void PyEditor_FindTool::update()
361 {
362   find();
363 }
364
365 /*!
366   \brief Slot: activate action; called when user types corresponding shortcut.
367   \param action Action being activated.
368   \internal
369 */
370 void PyEditor_FindTool::activate( int action )
371 {
372   QTextCursor cursor = myEditor->textCursor();
373   cursor.movePosition( QTextCursor::StartOfWord );
374   cursor.movePosition( QTextCursor::EndOfWord, QTextCursor::KeepAnchor );
375   QString word = cursor.selectedText();
376
377   switch ( action )
378   {
379   case Find:
380   case Replace:
381     showReplaceControls( action == Replace );
382     show();
383     if ( !word.isEmpty() ) {
384       myFindEdit->setText( word );
385       myEditor->setTextCursor( cursor );
386     }
387     myFindEdit->setFocus();
388     myFindEdit->selectAll();
389     find( myFindEdit->text() );
390     break;
391   case FindPrevious:
392     findPrevious();
393     break;
394   case FindNext:
395     findNext();
396     break;
397   default:
398     break;
399   }
400 }
401
402 /*!
403   \brief Get shortcuts for given action.
404   \param action Editor's action.
405   \return List of shortcuts.
406   \internal
407 */
408 QList<QKeySequence> PyEditor_FindTool::shortcuts( int action ) const
409 {
410   QList<QKeySequence> bindings;
411   switch ( action )
412   {
413   case Find:
414     bindings << QKeySequence( QKeySequence::Find );
415     break;
416   case FindPrevious:
417     bindings << QKeySequence( QKeySequence::FindPrevious );
418     break;
419   case FindNext:
420     bindings << QKeySequence( QKeySequence::FindNext );
421     break;
422   case Replace:
423     bindings << QKeySequence( "Ctrl+H" );
424     bindings << QKeySequence( QKeySequence::Replace );
425     break;
426   default:
427     break;
428   }
429   return bindings;
430 }
431
432 /*!
433   \brief Update shortcuts when widget is enabled / disabled.
434   \internal
435 */
436 void PyEditor_FindTool::updateShortcuts()
437 {
438   foreach ( QAction* action, actions().mid( Find ) )
439   {
440     action->setEnabled( isEnabled() && myEditor->isEnabled() );
441   }
442 }
443
444 /*!
445   \brief Show / hide 'Replace' controls.
446   \param on Visibility flag.
447   \internal
448 */
449 void PyEditor_FindTool::showReplaceControls( bool on )
450 {
451   QGridLayout* l = qobject_cast<QGridLayout*>( layout() );
452   for ( int j = 0; j < l->columnCount(); j++ )
453   {
454     if ( l->itemAtPosition( 1, j )->widget() )
455       l->itemAtPosition( 1, j )->widget()->setVisible( on );
456   }
457 }
458
459 /*!
460   \brief Set palette for 'Find' tool depending on results of search.
461   \param found Search result: true in case of success; false otherwise.
462   \internal
463 */
464 void PyEditor_FindTool::setSearchResult( bool found )
465 {
466   QPalette pal = myFindEdit->palette();
467   QPalette ref = myReplaceEdit->palette();
468   pal.setColor( QPalette::Active, QPalette::Text,
469                 found ? ref.color( QPalette::Active, QPalette::Text ) : QColor( 255, 0, 0 ) );
470   myFindEdit->setPalette( pal );
471 }
472
473 /*!
474   \brief Get 'Use regular expression' search option.
475   \return true if option is switched on; false otherwise.
476   \internal
477 */
478 bool PyEditor_FindTool::isRegExp() const
479 {
480   return actions()[RegExp]->isChecked();
481 }
482
483 /*!
484   \brief Get 'Case sensitive search' search option.
485   \return true if option is switched on; false otherwise.
486   \internal
487 */
488 bool PyEditor_FindTool::isCaseSensitive() const
489 {
490   return actions()[CaseSensitive]->isChecked();
491 }
492
493 /*!
494   \brief Get 'Whole words only' search option.
495   \return true if option is switched on; false otherwise.
496   \internal
497 */
498 bool PyEditor_FindTool::isWholeWord() const
499 {
500   return actions()[WholeWord]->isChecked();
501 }
502
503 /*!
504   \brief Get search options.
505   \param back Search direction: backward if false; forward otherwise.
506   \return List of options
507   \internal
508 */
509 QTextDocument::FindFlags PyEditor_FindTool::searchFlags( bool back ) const
510 {
511   QTextDocument::FindFlags flags = 0;
512   if ( isCaseSensitive() )
513     flags |= QTextDocument::FindCaseSensitively;
514   if ( isWholeWord() )
515     flags |= QTextDocument::FindWholeWords;
516   if ( back )
517     flags |= QTextDocument::FindBackward;
518   return flags;
519 }
520
521 /*!
522   \brief Get all matches from Python editor.
523   \param text Text being searched.
524   \return List of all matches.
525   \internal
526 */
527 QList<QTextCursor> PyEditor_FindTool::matches( const QString& text ) const
528 {
529   QList<QTextCursor> results;
530
531   QTextDocument* document = myEditor->document();
532
533   QTextCursor cursor( document );
534   while ( !cursor.isNull() )
535   {
536     cursor = isRegExp() ? 
537       document->find( QRegExp( text, isCaseSensitive() ?
538                                Qt::CaseSensitive : Qt::CaseInsensitive ),
539                       cursor, searchFlags() ) : 
540       document->find( text, cursor, searchFlags() );
541     if ( !cursor.isNull() )
542       results.append( cursor );
543   }
544   return results;
545 }
546
547 /*!
548   \brief Find specified text.
549   \param text Text being searched.
550   \param delta Search direction.
551   \internal
552 */
553 void PyEditor_FindTool::find( const QString& text, int delta )
554 {
555   QTextCursor cursor = myEditor->textCursor();
556   int position = qMin( cursor.position(), cursor.anchor() ) + delta;
557   cursor.setPosition( position );
558   myEditor->setTextCursor( cursor );
559
560   QList<QTextCursor> results = matches( text );
561
562   int index = -1;
563   if ( !results.isEmpty() )
564   {
565     if ( delta >= 0 )
566     {
567       // search forward
568       if ( position > results.last().anchor() )
569         position = 0;
570       for ( int i = 0; i < results.count() && index == -1; i++ )
571       {
572         QTextCursor result = results[i];
573         if ( result.hasSelection() && position <= result.anchor() )
574         {
575           index = i;
576         }
577       }
578     }
579     else
580     {
581       // search backward
582       if ( position < results.first().position() )
583         position = results.last().position();
584
585       for ( int i = results.count()-1; i >= 0 && index == -1; i-- )
586       {
587         QTextCursor result = results[i];
588         if ( result.hasSelection() && position >= result.position() )
589         {
590           index = i;
591         }
592       }
593     }
594   }
595   if ( index != -1 )
596   {
597     myInfoLabel->setText( tr( "NB_MATCHED_LABEL" ).arg( index+1 ).arg( results.count() ) );
598     myEditor->setTextCursor( results[index] );
599   }
600   else
601   {
602     myInfoLabel->clear();
603     cursor.clearSelection();
604     myEditor->setTextCursor( cursor );
605   }
606
607   setSearchResult( text.isEmpty() || !results.isEmpty() );
608 }
609
610 /*!
611   \brief Add completion.
612   \param text Completeion being added.
613   \param replace true to add 'Replace' completion; false to add 'Find' completion.
614   \internal
615 */
616 void PyEditor_FindTool::addCompletion( const QString& text, bool replace )
617 {
618   QStringListModel& model = replace ? myReplaceCompletion : myFindCompletion;
619
620   QStringList completions = model.stringList();
621   if ( !text.isEmpty() && !completions.contains( text ) )
622   {
623     completions.prepend( text );
624     model.setStringList( completions );
625   }
626 }