In the previous section we constructed a
deviously complex (at least, it felt that way) modal dialog box.
Now we will attempt something comparable for a non-modal dialog
box.
Integration in the application
Implementing all this functionality is
quite complex, so it is best to first make sure that we can
call the find and replace dialog window from the application.
This entails adding two QAction's to
the action dictionary, an icon to
resources.py, and two new slots—and
creating the dialog, of course.
You don't create, run and destroy a
non-modal dialog, like we did with the settings dialog.
Instead, you create it once, and show it whenever necessary.
The Close button on the dialog doesn't really close it; it
merely hides the window. In this case, the find and replace
dialog is created in the constructor of
KalamApp:
...
from dlgfindreplace import DlgFindReplace
...
class KalamApp(QMainWindow):
def __init__(self, *args):
apply(QMainWindow.__init__,(self, ) + args)
...
# Create the non-modal dialogs
self.dlgFindReplace = DlgFindReplace(self, "Find and replace")
There are two actions defined: one for
search, and one for find and replace. Again, the "find" icon
is the standard KDE 2 icon for find operations.
def initActions(self):
self.actions = {}
...
#
# Edit actions
#
...
self.actions["editFind"] = QAction("Find",
QIconSet(QPixmap(editfind)),
"&Find",
QAccel.stringToKey("CTRL+F"),
self)
self.connect(self.actions["editFind"],
SIGNAL("activated()"),
self.slotEditFind)
self.actions["editReplace"] = QAction("Replace",
"&Replace",
QAccel.stringToKey("CTRL+R"),
self)
self.connect(self.actions["editReplace"],
SIGNAL("activated()"),
self.slotEditReplace)
By now, you probably know what comes
next: adding the actions to the menu bar, and to the toolbar.
Since there isn't an icon for replace, it cannot be added to
the toolbar:
def initMenuBar(self):
...
self.editMenu = QPopupMenu()
...
self.editMenu.insertSeparator()
self.actions["editFind"].addTo(self.editMenu)
self.actions["editReplace"].addTo(self.editMenu)
self.menuBar().insertItem("&Edit", self.editMenu)
...
def initToolBar(self):
...
self.editToolbar = QToolBar(self, "edit operations")
...
self.actions["editFind"].addTo(self.editToolbar)
Because the combined find/find and
replace dialog has two modes, it is necessary to have two ways
of calling it—one for find, and one for find and
replace. The dialog should work on the current document and
the current view, but it is difficult to determine if
‘current' should be the current document and view when
the dialog is opened, as opposed to the document and view that
have focus. The user might, after all, change document and
view while the find dialog is open, or even close them. For
now, let's use the document and view that are open when the
dialog is shown.
def slotEditFind(self):
self.dlgFindReplace.showFind(self.docManager.activeDocument(),
self.workspace.activeWindow())
def slotEditReplace(self):
self.dlgFindReplace.showReplace(self.docManager.activeDocument(),
self.workspace.activeWindow())
The actual implementation in
DlgFindReplace of these show function
is quite simple. The Find option hides certain widgets, after
which the automatic layout management ensures that the dialog
looks as good as it should. The Find and Replace options makes
sure they are shown. The window caption is adapted, too. Note
that you must first call show() on the
entire dialog, and only then show() on
the previously hidden widgets, otherwise the layout manager
doesn't show the appearing widgets.
def showFind(self, document, view):
FrmFindReplace.show(self)
self.setCaption("Find in " + document.title())
self.bnReplaceNext.hide()
self.bnReplaceAll.hide()
self.grpReplace.hide()
self.initOptions(document, view)
def showReplace(self, document, view):
FrmFindReplace.show(self)
self.setCaption("Find and replace in " + document.title())
self.bnReplaceNext.show()
self.bnReplaceAll.show()
self.grpReplace.show()
self.initOptions(document, view)
The result is pretty enough to show:
Implementation of the functionality
Now that we can show the find and
replace dialog, it is time to implement some functionality.
Again, we subclass the generated design and add what we need.
"""
dlgfindreplace.py - Findreplace dialog for Kalam.
See: frmfindreplace.ui
copyright: (C) 2001, Boudewijn Rempt
email: boud@rempt.xs4all.nl
"""
import os, sys
from qt import *
import kalamconfig
from resources import TRUE, FALSE
from frmfindreplace import FrmFindReplace
class DlgFindReplace(FrmFindReplace):
""" A full-featured search and replace dialog.
"""
def __init__(self,
parent = None,
name = None):
FrmFindReplace.__init__(self, parent, name, FALSE, Qt.WStyle_Dialog)
self.connect(self.bnFind,
SIGNAL("clicked()"),
self.slotFindNext)
self.connect(self.bnReplaceNext,
SIGNAL("clicked()"),
self.slotReplaceNext)
self.connect(self.bnReplaceAll,
SIGNAL("clicked()"),
self.slotReplaceAll)
self.connect(self.radioRegexp,
SIGNAL("clicked()"),
self.slotRegExp)
self.connect(self.chkCaseSensitive,
SIGNAL("clicked()"),
self.slotCaseSensitive)
self.connect(self.chkWholeText,
SIGNAL("clicked()"),
self.slotBeginning)
self.connect(self.chkSelection,
SIGNAL("clicked()"),
self.slotSelection)
self.connect(self.radioForward,
SIGNAL("clicked()"),
self.slotForward)
self.connect(self.radioBackward,
SIGNAL("clicked()"),
self.slotBackward)
In the constructor we connect all relevant
clicked() signals to their slots. The rest
of the initialization (such as determining which text or part
of text we will work on) is moved to the show()
function. The same instance of the dialog can be used for
different documents.
def showFind(self, document, view):
FrmFindReplace.show(self)
self.bnFind.setDefault(TRUE)
self.setCaption("Find in " + document.title())
self.bnReplaceNext.hide()
self.bnReplaceAll.hide()
self.grpReplace.hide()
self.cmbFind.setFocus()
self.init(document, view)
def showReplace(self, document, view):
FrmFindReplace.show(self)
self.setCaption("Find and replace in " + document.title())
self.bnReplaceNext.show()
self.bnReplaceNext.setDefault(TRUE)
self.bnReplaceAll.show()
self.grpReplace.show()
self.cmbFind.setFocus()
self.init(document, view)
As we discussed above, there are two show functions
(showFind() and
showReplace), each hides or shows the
widgets that are relevant. The show functions also call the
initialization function init().
def init(self, document, view):
self.document = document
self.view = view
if view.hasSelection():
self.chkSelection.setChecked(TRUE)
self.setFindExtent()
The init()
function sets the document and view variables. Most of the
work is done directly on the view, making use of its
functionality for inserting, deleting and selecting text. This
is because whenever a string is found, it will be selected.
Asking the document to select a string will cause it to select
the string in all views of that document, which would be quite
confusing for the user.
If there is already a selection present
in the view, the "find in selection" checkbox is checked. This
is convenient, because when a user presses find after having
selected a section of text, he most likely wants the search to
be performed in that selection only.
The function
setFindExtent() (which we will examine in
detail later in this section) determines which part of the
text should be searched: from the cursor position to the end,
to the beginning, or between the beginning and end of a
selection. The find routine keeps track of where it is within
a search extent, using the variable
self.currentPosition, which is initially
the same as the start position of the extent.
#
# Slot implementations
#
def slotRegExp(self):
if self.radioRegexp.isChecked():
self.radioForward.setChecked(TRUE)
self.grpDirection.setEnabled(FALSE)
else:
self.grpDirection.setEnabled(TRUE)
If you are using Qt 2.3, you cannot use
regular expressions to search backwards. In Qt 3.0 the regular
expression object QRegExp has been
greatly extended, and can be used to search both forwards and
backwards. When Kalam was written,
Qt 3.0 was still in beta. Therefore, it was necessary to include
code to disable the forward/backward checkboxes whenever the
user selects the regular expressions search mode, and code to
make forward searching the default.
On regular expressions: It is quite probable that you know
more about regular expressions than I do. I can't write them
for toffee, and I find reading regular expressions to be
even harder (despite the fact that I used to be a dab hand
at Snobol). Nonetheless, regular expressions are
indispensable when searching a text. Even I know how to use
$ to specify the end of input or \n to specify a new line.
Regular expressions are everywhere on a Unix system, and all
decent editors (as well as Python, Perl and most other
languages) support them. On Windows, you can enter regular
expressions in the search function of Word (or so I am
told).
A regular expression is nothing more
than an algebraic notation for characterizing a set of
strings. An expression represents a pattern that the regular
expression engine can use to match text. Python comes with
its own highly capable, high performance regular expression
engine, compared with which the regular expression engine in
Qt 2.3 is a bit puny. The regular expression engine of Qt
3.0 has been improved a lot, and is nearly as good as the
Python one.
According to the Qt online documentation, the Qt 2.3
QRegExp class recognized the
following primitives:
c - the character 'c'
. - any character (but only one)
^ - matches start of input
$ - matches end of input
[] - matches a defined set of characters. For
instance, [a-z] matches all lowercase ASCII
characters. Note that you can give a range with a dash
(-) and negate a set with a caron (^ - [^ab] match
anything that does contain neither a nor b)/
c* - matches a sequence of zero or more character c's
c+ - matches a sequence of one or more character c's
c? - matches an optional character c
\c - escape code for special characters such
as \, [, *, +, . etc.
\t - matches the TAB character (9)
\n - matches newline (10). For instance "else\n"
will find all occurrence of else that are followed with a
new line, and that are thus missing the obligatory closing
colon (:).
\r - matches return (13)
\s - matches a white space (defined as any character
for which QChar::isSpace() returns TRUE. This includes
at least ASCII characters 9 (TAB), 10 (LF), 11 (VT),
12(FF), 13 (CR) and 32 (Space)).
\d - matches a digit (defined as any character for
which QChar::isDigit() returns TRUE. This includes at
least ASCII characters '0'-'9').
\x1f6b - matches the character with unicode point
U1f6b (hexadecimal 1f6b). \x0012 will match the
ASCII/Latin1 character 0x12 (18 decimal, 12
hexadecimal).
\022 - matches the ASCII/Latin1 character 022 (18
decimal, 22 octal).
Being constitutionally unable to explain exactly
how you go about creating regular
expressions that work, I can only refer you to the online
documentation of Python and Qt, and to the many tutorials
available on the web. Qt 3.0 comes with an excellent page on
regular expressions, too. Whenever I read Part I of Jurafsky
and Martin's book ‘Speech and Language Processing', I have
the feeling that I understand regular expressions, and I
have never found that to be the case with any other text on the
subject.
As a last note: both Python and Qt regular expressions
work just fine with Unicode. Back to our code...
def slotCaseSensitive(self):
pass
def slotBeginning(self):
self.setFindExtent()
def slotSelection(self):
self.setFindExtent()
def slotForward(self):
self.setFindExtent()
def slotBackward(self):
self.setFindExtent()
Whenever the user alters one of the options that influence
the direction or area of search, the extent must be adapted.
#
# Extent calculations
#
def setSelectionExtent(self):
self.startSelection = self.view.selectionStart()
self.endSelection = self.view.selectionEnd()
self.startPosition = self.startSelection
self.endPosition = self.endSelection
def setBackwardExtent(self):
# Determine extent to be searched
if (self.chkWholeText.isChecked()):
self.endPosition = self.view.length()
else:
self.endPosition = self.view.getCursorPosition()
self.startPosition = 0
if self.chkSelection.isChecked():
if self.view.hasSelection():
setSelectionExtent()
self.currentPosition = self.endPosition
def setForwardExtent(self):
# Determine extent to be searched
if (self.chkWholeText.isChecked()):
self.startPosition = 0
else:
self.startPosition = self.view.getCursorPosition()
self.endPosition = self.view.length()
if self.chkSelection.isChecked():
if self.view.hasSelection():
setSelectionExtent()
self.currentPosition = self.startPosition
def setFindExtent(self):
if self.radioForward.isChecked():
self.setForwardExtent()
else:
self.setBackwardExtent()
Correctly determining which part of the
text should be searched is a fairly complex task. First, there
is an important difference between searching forwards and
backwards, if only because of the place where searching should
start. A further complication is caused by the option to
search either the whole text, or from the cursor position.
Note that begin and end mean the same thing with both
backwards and forwards searches; it is
currentPosition, where searching should
start, that is different between forward and backward
searches.
def wrapExtentForward(self):
if QMessageBox.information(self.parent(),
"Kalam",
"End reached. Start from beginning?",
"yes",
"no",
None,
0,
1) == 0:
self.endPosition = self.startPosition
self.startPosition = 0
self.currentPosition = 0
self.slotFindNext()
def wrapExtentBackward(self):
if QMessageBox.information(self.parent(),
"Kalam",
"Begin reached. Start from end?",
"yes",
"no",
None,
0,
1) == 0:
self.startPosition = self.endPosition
self.endPosition = self.view.length()
self.currentPosition = self.startPosition
self.previousOccurrence()
self.slotFindNext()
Whenever the current extent has been searched, the user
should be asked whether he or she wants to search the rest of
the text. The functions above are different for
forwards and backwards searching, too.
#
# Find functions
#
def nextOccurrence(self):
findText = self.cmbFind.currentText()
caseSensitive = self.chkCaseSensitive.isChecked()
if self.radioRegexp.isChecked():
# Note differences with Qt 3.0
regExp = QRegExp(findText,
caseSensitive)
pos, len = regExp.match(self.view.text(),
self.currentPosition,
FALSE)
return pos, pos+len
else:
pos = self.view.text().find(findText,
self.currentPosition,
caseSensitive)
return (pos, pos + findText.length())
Searching forwards can be done by plain text matching, or
with regular expressions.
Regular expressions are available from
both Python and PyQt. Python regular expressions (in the
re module) work on Python strings, while
PyQt regular expressions work on
QStrings. It is relatively inefficient
to convert a QString to a Python
string, so we use QRegExp here (though
it is a little underpowered in its Qt 2.3 incarnation compared
to re and Qt 3.0's
QRegExp).
A QRegExp is
constructed from a string that contains the patterns that
should be matched, and two options. The first option
determines whether the search should be case sensitive; the
second determines whether or not the search is a wildcard
search. Wildcard searches work like the filename expansion on
a Unix command line, and are not terribly useful for searching
in a text.
QRegExp has two
tools for searching: match() and
find(). Both take as parameters the
QString to be searched and the position
from which searching should start. However,
match() also returns the length of the
string that is found, and can take an optional parameter that
indicates whether the start position should match the regular
expression character "^" (start of input). You don't want this
for searching in editable text, so we make it FALSE by
default.
Literal searching is a simple matter of
applying the find() method of
QString from the current
position.
Looking for an occurrence returns
either -1, if nothing was found, or the begin and end
positions of the string that was found. Note that
QString.find()
doesn't return the length of the found string; we take the
length() of the search string to
determine the end position.
def previousOccurrence(self):
findText = self.cmbFind.currentText()
caseSensitive = self.chkCaseSensitive.isChecked()
pos = self.view.text().findRev(findText,
self.currentPosition,
caseSensitive)
return (pos, pos + findText.length())
Qt 2.3 doesn't yet support backwards
searching with regular expressions, so the function
previousOccurrence is quite a bit
simpler. Instead of
QString.find(),
QString.findRev()
is used - this searches backwards.
def slotFindNext(self):
if self.radioForward.isChecked():
begin, end = self.nextOccurrence()
if begin > -1:
self.view.setSelection(begin,
end)
self.currentPosition = end
return (begin, end)
else:
if (self.chkSelection.isChecked() == FALSE and
self.chkWholeText.isChecked() == FALSE):
self.wrapExtentForward()
return (self.currentPosition, self.currentPosition)
else:
begin, end = self.previousOccurrence()
if begin > -1:
self.view.setSelection(begin,
end)
self.currentPosition = begin -1
return (begin, end)
else:
if (self.chkSelection.isChecked() == FALSE and
self.chkWholeText.isChecked() == FALSE):
self.wrapExtentBackward()
return (self.currentPosition, self.currentPosition)
The slotFindNext
slot is the central bit of intelligence in this class.
Depending upon the selected direction, the next or previous
occurrence of the search string is searched. If an occurrence
is found (when begin is greater
than -1), it is selected, and the current position is moved.
If there are no more matches, the user is asked whether he or
she wants to go on with the rest of the document.
def slotReplaceNext(self):
begin, end = self.slotFindNext()
if self.view.hasSelection():
self.view.replaceSelection(self.cmbReplace.currentText())
return begin, end
else:
return -1, -1
def slotReplaceAll(self):
begin, end = self.slotReplaceNext()
while begin > -1:
begin, end = self.slotReplaceNext()
print begin, end
Replacing is one part finding, one part
replacing. The slotFindNext() code is
reused, which is one good reason for creating a dialog that
has both a find and a find and replace mode.
slotFindNext() already selects the match,
so replacing is a simple matter of deleting the match and
inserting the replacement string. This is done with a new
function in KalamView:
...
class KalamView(QWidget):
...
def replaceSelection(self, text):
self.editor.deleteChar()
self.editor.insert(text)
self.editor.emit(SIGNAL("textChanged()"),())
Messing about with the text in a
QMultiLineEdit widget has a few tricky
points. You should avoid trying to directly change the
QString that you retrieve with
QMultiLineEdit.text()—
changing this string behind the editor's back is a sure recipe
for a beautiful crash. QMultiLineEdit
has several functions, such as
deleteChar() (which not only deletes
characters, but also the selection, if there is one), to alter
the contents. However, these functions don't emit the
textChanged() signal— you will have
to do that yourself. If we do not emit
textChanged(), other views on the same
document won't know of the changes, nor will the document
itself know it has been changed.
Another interesting complication occurs
because QMultiLineEdit, the editor
widget used in KalamView, works with
line and column positioning, not with a position within the
string that represents the text. This makes it necessary to
create conversion functions between string index and editor
line / column position in KalamView,
which is potentially very costly business for long
files:
...
class KalamView(QWidget):
...
def getLineCol(self, p):
i=p
for line in range(self.editor.numLines()):
if i < self.editor.textLine(line).length():
return (line, i)
else:
# + 1 to compensate for \n
i-=(self.editor.textLine(line).length() + 1)
# fallback
return (0,0)
def setCursorPosition(self, p):
"""Sets the cursor of the editor at position p in the text."""
l, c = self.getLineCol(p)
self.editor.setCursorPosition(l, c)
def getPosition(self, startline, startcol):
if startline = 0:
return startcol
if startline > self.editor.numLines():
return self.editor.text().length()
i=0
for line in range(self.editor.numLines()):
if line < startline:
i += self.editor.textLine(line).length()
else:
return i + startcol
def getCursorPosition(self):
"""Get the position of the cursor in the text"""
if self.editor.atBeginning():
return 0
if self.editor.atEnd():
return self.editor.text().length()
l, c = self.editor.getCursorPosition()
return self.getPosition(l, c)
The function
getLineCol() takes a single index
position as argument. It then loops through the lines of the
editor, subtracting the length of each line from a temporary
variable, until the length of the current line is greater than
the remaining number of characters. Then we have linenumber
and column.
The same, but in reverse is necessary in
getPosition to find out how far into a
string the a certain line number and column position
is. There are a few safeguards and optimizations, but not
quite enough.
Qt 3 offers the
QTextEdit class, which is vastly more
powerful than QMultiLineEdit. For
instance, QTextEdit sports a built-in
find function. Internally, QTextEdit is
associated with QTextDocument, which is
comparable to our KalamDocument. But you
can't get at QTextDocument (it's not
even documented, you need to read the Qt source code to find
out about it), so it's not a complete replacement for our
document-view architecture. The external rich text
representation of QTextEdit is a subset
of html, which makes it less suitable for a programmer's
editor. You have to choose: either colorized, fontified
text, and filter the html codes out yourself, or a plain text
editor. Fortunately, Qt 3 still includes
QMultiLineEdit for compatibility
reasons.