root/trunk/peppy/plugins/find_replace.py

Revision 1592, 48.9 kB (checked in by rob, 11 days ago)

Fixed #516: added boolean or to the regex flags (fixing typo)

Line 
1# peppy Copyright (c) 2006-2008 Rob McMullen
2# Licenced under the GPLv2; see http://peppy.flipturn.org for more info
3"""Some simple text transformation actions.
4
5This plugin is a collection of some simple text transformation actions that
6should be applicable to more than one major mode.
7"""
8
9import os, glob, re
10
11import wx
12import wx.lib.stattext
13from wx.lib.pubsub import Publisher
14
15from peppy.yapsy.plugins import *
16from peppy.actions.minibuffer import *
17
18from peppy.about import AddCredit
19from peppy.fundamental import FundamentalMode
20from peppy.actions import *
21from peppy.actions.base import *
22from peppy.debug import *
23
24AddCredit("Jan Hudec", "for the shell-style wildcard to regex converter from bzrlib")
25
26
27class ReplacementError(Exception):
28    pass
29
30
31class FindSettings(debugmixin):
32    def __init__(self, match_case=False, smart_case=True):
33        self.match_case = match_case
34        self.smart_case = smart_case
35        self.find = ''
36        self.find_user = ''
37        self.replace = ''
38        self.replace_user = ''
39   
40    def serialize(self, storage):
41        storage['last_search'] = self.find_user
42        storage['last_replace'] = self.replace_user
43
44    def unserialize(self, storage):
45        if 'last_search' in storage:
46            self.find_user = storage['last_search']
47        else:
48            self.find_user = ''
49        if 'last_replace' in storage:
50            self.replace_user = storage['last_replace']
51        else:
52            self.replace_user = ''
53
54
55class FindService(debugmixin):
56    forward = "Find"
57    backward = "Find backward"
58   
59    help = """The default find and replace uses literal strings.  No wildcard
60    or regular expressions are used to match text."""
61   
62    def __init__(self, stc, settings=None):
63        self.flags = 0
64        self.stc = stc
65        if settings is None:
66            self.settings = FindSettings()
67        else:
68            self.settings = settings
69       
70        if self.settings.find_user:
71            self.setFindString(self.settings.find_user)
72        if self.settings.replace:
73            self.setReplaceString(self.settings.replace_user)
74   
75    def serialize(self, storage):
76        self.settings.serialize(storage)
77   
78    def unserialize(self, storage):
79        self.settings.unserialize(storage)
80        self.setFindString(self.settings.find_user)
81        self.setReplaceString(self.settings.replace_user)
82   
83    def allowBackward(self):
84        return True
85   
86    def setFlags(self):
87        text = self.settings.find
88       
89        if text != text.lower():
90            match_case = True
91        else:
92            match_case = self.settings.match_case
93       
94        if match_case:
95            self.flags = wx.stc.STC_FIND_MATCHCASE
96        else:
97            self.flags = 0
98   
99    def getFlags(self, user_flags=0):
100        if self.settings.match_case != self.stc.locals.case_sensitive_search:
101            self.settings.match_case = self.stc.locals.case_sensitive_search
102            self.setFlags()
103       
104        return self.flags | user_flags
105       
106    def expandSearchText(self, findTxt):
107        """Convert the search string that the user enters into the string
108        actually searched for by the service.
109       
110        This is designed to be overridden in subclasses, and has most use for
111        regular expression searches where the string the user types has to be
112        compiled into a form that the regular expression engine uses to search.
113        """
114        if not findTxt:
115            findTxt = ""
116           
117        return findTxt
118   
119    def expandReplaceText(self, text):
120        """Convert the replacement string that the user enters into the string
121        actually searched for by the service.
122       
123        Like L{expandSearchText}, this is primarily designed to be overridden
124        in subclasses.
125        """
126        return self.expandSearchText(text)
127   
128    def setFindString(self, text):
129        self.settings.find_user = text
130        self.settings.find = self.expandSearchText(text)
131        self.setFlags()
132   
133    def setReplaceString(self, text):
134        self.settings.replace_user = text
135        self.settings.replace = self.expandReplaceText(text)
136
137    def getReplacement(self, replacing):
138        """Return the string that will be substituted in the text
139       
140        @param replacing: the original string from the document
141        @return: the string after the substitutions have been made
142        """
143        findTxt = self.settings.find
144        replaceTxt = self.settings.replace
145       
146        # in order to use smart casing, both the find and replace strings
147        # must be lower case and the target string needs to have at least one
148        # alphabetic character.  If the target converted to upper case is the
149        # same as the target converted to lower case, we know that it doesn't
150        # have any alphabetic chars in it so we punt and use the replace
151        # string as is.
152        if self.settings.smart_case and findTxt.lower() == findTxt and replaceTxt.lower() == replaceTxt and replacing.upper() != replacing.lower():
153            if replacing.upper() == replacing:
154                ## print "all upper", replacing
155                replaceTxt = replaceTxt.upper()
156            elif replacing.lower() == replacing:
157                ## print "all lower", replacing
158                replaceTxt = replaceTxt.lower()
159            elif len(replacing) == len(replaceTxt):
160                ## print "smartcasing", replacing
161                r = []
162                for i,j in zip(replacing, replaceTxt):
163                    if i.isupper():
164                        r.append(j.upper())
165                    elif i.islower():
166                        r.append(j.lower())
167                    else:
168                        r.append(j)
169                replaceTxt = ''.join(r)
170            elif replacing and replaceTxt and replacing[:1].upper() == replacing[:1]:
171                ## print "first upper", replacing
172                replaceTxt = replaceTxt[:1].upper() + replaceTxt[1:]
173            elif replacing and replaceTxt and replacing[:1].lower() == replacing[:1]:
174                ## print "first lower", replacing
175                replaceTxt = replaceTxt[:1].lower() + replaceTxt[1:]
176       
177        return replaceTxt
178
179    def getRange(self, start, chars, dire=1):
180        end = start
181        if dire==1:
182            fcn = self.stc.PositionAfter
183        else:
184            fcn = self.stc.PositionBefore
185        for i in xrange(chars):
186            z = fcn(end)
187            y = self.stc.GetCharAt(end)
188            x = abs(z-end)==2 and (((dire==1) and (y == 13)) or ((dire==0) and (y == 10)))
189            ## print y, z-end
190            end = z - x
191        return start, end
192   
193    def highlightSelection(self, pos, count):
194        sel_start, sel_end = self.getRange(pos, count)
195        #dprint("selection = %d - %d" % (sel_start, sel_end))
196        self.stc.SetSelection(sel_start, sel_end)
197   
198    def findMatchLength(self, pos):
199        """Scintilla doesn't return the end of the match, so we have to compute
200        it ourselves.
201       
202        This is designed to be overridden in subclasses, particularly by
203        subclasses that use regular expressions to match.
204       
205        This can also be used as a verification method to reject a match.
206        Should a subclass need this capability, return a -1 here and the
207        L{doFindNext} method will move to the next possible match and try
208        again.  This will repeat until a match is found or the end of the
209        document is reached.
210       
211        @return: number of characters matched by the search string, or -1 to
212        reject the match.
213        """
214        count = len(self.settings.find)
215        return count
216   
217    def doFindNext(self, start=-1, incremental=False):
218        """Find and highlight the next match in the document.
219       
220        @param start: starting position in text, or -1 to use current position
221       
222        @param incremental: True if an incremental search that will be added to
223        the current search result
224       
225        @return: tuple containing the position of the match or -1 if no match
226        is found, and the position of the original start of the search.  If
227        the search string is invalid, a tuple of (None, None) is returned.
228        """
229        if not self.settings.find:
230            return None, None
231        flags = self.getFlags(wx.FR_DOWN)
232       
233        #handle finding next item, handling wrap-arounds as necessary
234        if start < 0:
235            sel = self.stc.GetSelection()
236            if incremental:
237                start = min(sel)
238            else:
239                start = max(sel)
240       
241        while True:
242            pos = self.stc.FindText(start, self.stc.GetTextLength(), self.settings.find, flags)
243            #dprint("start=%d find=%s flags=%d pos=%d" % (start, self.settings.find, flags, pos))
244       
245            if pos < 0:
246                # no match; done.
247                break
248           
249            count = self.findMatchLength(pos)
250            if count >= 0:
251                self.highlightSelection(pos, count)
252                break
253           
254            start = pos + 1
255       
256        return pos, start
257
258    def doFindPrev(self, start=-1, incremental=False):
259        """Find and highlight the previous match in the document.
260       
261        @param start: starting position in text, or -1 to use current position
262       
263        @param incremental: True if an incremental search that will be added to
264        the current search result
265       
266        @return: tuple containing the position of the match or -1 if no match
267        is found, and the position of the original start of the search.  If
268        the search string is invalid, a tuple of (None, None) is returned.
269        """
270        if not self.settings.find:
271            return None, None
272        flags = self.getFlags()
273       
274        if start < 0:
275            sel = self.stc.GetSelection()
276            if incremental:
277                start = min(sel) + len(self.settings.find)
278                #dprint("sel=%s min=%d len=%s" % (str(sel), min(sel), len(self.settings.find)))
279            else:
280                start = min(sel)
281        pos = self.stc.FindText(start, 0, self.settings.find, flags)
282       
283        if pos >= 0:
284            count = self.findMatchLength(pos)
285            self.highlightSelection(pos, count)
286       
287        return pos, start
288
289    def doReplace(self):
290        """Replace the selection
291       
292        Replace the current selection in the stc with the replacement text
293        """
294        sel = self.stc.GetSelection()
295        replacing = self.stc.GetTextRange(sel[0], sel[1])
296
297        replacement = self.getReplacement(replacing)
298       
299        self.stc.ReplaceSelection(replacement)
300        self.stc.SetSelection(min(sel), min(sel) + len(replacement.encode('utf-8')))
301
302
303class FindBasicRegexService(FindService):
304    forward = "Find Scintilla regex"
305   
306    help = r"""
307    Basic regular expressions are a limited form supported by the scintilla
308    editor, not the full python regular expressions.  Scintilla regular
309    expressions are limited to:
310
311.       Matches any character
312\(      This marks the start of a region for tagging a match.
313\)      This marks the end of a tagged region.
314\n      Where n is 1 through 9 refers to the first through ninth tagged region when replacing. For example if the search string was Fred\([1-9]\)XXX and the replace string was Sam\1YYY applied to Fred2XXX this would generate Sam2YYY.
315\<      This matches the start of a word using Scintilla's definitions of words.
316\>      This matches the end of a word using Scintilla's definition of words.
317\x      This allows you to use a character x that would otherwise have a special meaning. For example, \[ would be interpreted as [ and not as the start of a character set.
318[...]   This indicates a set of characters, for example [abc] means any of the characters a, b or c. You can also use ranges, for example [a-z] for any lower case character.
319[^...]  The complement of the characters in the set. For example, [^A-Za-z] means any character except an alphabetic character.
320^       This matches the start of a line (unless used inside a set, see above).
321$       This matches the end of a line.
322*       This matches 0 or more times. For example Sa*m matches Sm, Sam, Saam, Saaam and so on.
323+       This matches 1 or more times. For example Sa+m matches Sam, Saam, Saaam and so on."""
324   
325    def __init__(self, stc, settings=None):
326        FindService.__init__(self, stc, settings)
327   
328    def allowBackward(self):
329        return False
330   
331    def findMatchLength(self, pos):
332        """Determine the length of the regular expression match
333        """
334        line = self.stc.LineFromPosition(pos)
335        last = self.stc.GetLineEndPosition(line)
336        self.stc.SetTargetStart(pos)
337        self.stc.SetTargetEnd(last)
338        self.stc.SetSearchFlags(self.flags)
339        found = self.stc.SearchInTarget(self.settings.find)
340        last = self.stc.GetTargetEnd()
341        #dprint("Target: found=%d pos=%d last=%d" % (found, pos, last))
342        if found >= 0:
343            return last - pos
344        return -1
345   
346    def setFlags(self):
347        self.flags = self.getFlags(wx.stc.STC_FIND_REGEXP)
348   
349    def doReplace(self):
350        """Replace the selection
351       
352        Replace the current selection in the stc with the replacement text
353        """
354        sel = self.stc.GetSelection()
355        self.stc.SetTargetStart(sel[0])
356        self.stc.SetTargetEnd(sel[1])
357        self.stc.SetSearchFlags(self.flags)
358        #dprint("target = %d - %d" % (sel[0], sel[1]))
359        count = self.stc.ReplaceTargetRE(self.settings.replace)
360
361        self.stc.SetSelection(min(sel), min(sel) + count)
362
363
364class FindWildcardService(FindService):
365    """Find and replace using shell style wildcards.
366   
367    Simpler than regular expressions, shell style wildcards allow simple
368    search and replace on patterns.  Internally, they are converted to regular
369    expressions, but to the user's perspective they are simple, less powerful
370    pattern matching.
371   
372    Replacements can be made using the wildcard characters -- the same set of
373    wildcard characters must be given in the replacement string, in the same
374    order.  For instance, if the find string is:
375   
376    blah*blah???blah*
377   
378    the replacement string should have the wildcards in the same order: '*',
379    '?', '?', '?', and '*'.  E.g.:
380   
381    stuff*stuff?stuff?stuff?stuff*stuff
382    """
383    forward = "Find wildcard"
384   
385    help = """
386    Shell-style wildcards are simpler than full regular expressions, but you
387    give up some power for the simplicity.  '*' and '?' are the wildcards,
388    where '*' matches zero or more non-whitespace characters and '?' matches
389    exactly one non-whitespace character.
390   
391    Wildcards are also allowed in replacement strings, where each wildcard in
392    the replacement string will be replaced by the value found in the search
393    result matched by the corresponding wildcard character.  For instance, if
394    the find string is:
395   
396    a*b???c*
397   
398    and finds the match a222b345c678, given the replacement string:
399   
400    d*e?f?g?h*i
401   
402    the resulting replacement will be:
403   
404    d222e3f4g5h678i"""
405   
406    def findMatchLength(self, pos):
407        """Have to convert a scintilla regex to a python one so we can find out
408        how many characters the regex matched.
409        """
410        pyre = self.settings.find.replace(r"\(.*\)", r"([^ \t\n\r]*)").replace(r"\(.\)", r"([^ \t\n\r])")
411       
412        line = self.stc.LineFromPosition(pos)
413        last = self.stc.GetLineEndPosition(line)
414        text = self.stc.GetTextRange(pos, last)
415        match = re.match(pyre, text, flags=re.IGNORECASE)
416        if match:
417            #dprint(match.group(0))
418            return len(match.group(0))
419        #dprint("Not matched!!! pat=%s text=%s pos=%d last=%d" % (pyre, text, pos, last))
420        return -1
421   
422    def setFlags(self):
423        self.flags = self.getFlags(wx.stc.STC_FIND_REGEXP)
424   
425    def expandSearchText(self, text):
426        """Convert from shell style wildcards to regular expressions"""
427        import peppy.lib.glob
428       
429        if not text:
430            text = ""
431        regex = peppy.lib.glob.translate(text)
432        #dprint(regex)
433       
434        return regex
435
436    def expandReplaceText(self, text):
437        if not text:
438            text = ""
439        return text
440   
441#    def repgroup(self, m):
442#        dprint("match=%s span=%s" % (m.group(), str(m.span())))
443#        self._group_count += 1
444#        return "\\%d" % self._group_count
445#
446#    def expandReplaceText(self, text):
447#        """Convert from shell style wildcards to regular expressions"""
448#        import peppy.lib.glob
449#       
450#        if not text:
451#            text = ""
452#        regex = peppy.lib.glob.translate(text)
453#        dprint(regex)
454#       
455#        groups = r"([\\][(].+[\\][)])"
456#        self._group_count = 0
457#        regex = re.sub(groups, self.repgroup, regex)
458#        dprint(regex)
459#       
460#        return regex
461   
462    def getReplacement(self, replacing):
463        """Get the replacement text.
464       
465        Can't use the built-in scintilla regex replacement method, because
466        it seems that scintilla regexs are greedy and ignore the target end
467        specified by SetTargetEnd.  So, we have to convert to a python regex
468        and replace that way.
469        """
470        pyre = self.settings.find.replace(r"\(.*\)", r"([^ \t\n\r]*)").replace(r"\(.\)", r"([^ \t\n\r])")
471        #dprint("replacing %s: %s, %s" % (replacing, pyre, self.settings.replace))
472        matches = re.match(pyre, replacing, flags=re.IGNORECASE)
473        if matches and matches.groups():
474            groups = matches.groups()
475        else:
476            groups = []
477        #dprint("matches: %s" % str(groups))
478       
479        output = []
480        index = 0
481        # replace each wildcard with the corresponding match from the search
482        # string
483        for c in self.settings.replace:
484            if c in '*?':
485                if index < len(groups):
486                    output.append(groups[index])
487                else:
488                    output.append("")
489                index += 1
490            else:
491                output.append(c)
492        text = "".join(output)
493        return text
494
495
496class FindRegexService(FindService):
497    """Find and replace using python regular expressions.
498   
499    """
500    forward = "Find regex"
501
502    def __init__(self, *args, **kwargs):
503        FindService.__init__(self, *args, **kwargs)
504        self.regex = None
505        self.shadow = None
506
507    def setFlags(self):
508        text = self.settings.find
509       
510        if text != text.lower():
511            match_case = True
512        else:
513            match_case = self.settings.match_case
514       
515        self.flags = re.MULTILINE
516        if not match_case:
517            self.flags |= re.IGNORECASE
518       
519        # Force the next search to start from a new shadow copy of the text
520        self.shadow = None
521   
522    def getFlags(self, user_flags=0):
523        if hasattr(self.stc, 'locals') and self.settings.match_case != self.stc.locals.case_sensitive_search:
524            self.settings.match_case = self.stc.locals.case_sensitive_search
525            self.setFlags()
526       
527        flags = self.flags | user_flags
528       
529        try:
530            self.regex = re.compile(self.settings.find, flags)
531        except re.error:
532            self.regex = None
533        return flags
534   
535    def verifyShadow(self, start=-1, incremental=False):
536        if self.shadow is None or start >= 0:
537            #handle finding next item, handling wrap-arounds as necessary
538            if start < 0:
539                sel = self.stc.GetSelection()
540                if incremental:
541                    start = min(sel)
542                else:
543                    start = max(sel)
544            self.shadow = self.stc.GetTextRange(start, self.stc.GetTextLength())
545            self.shadow_equiv_pos = 0
546            self.stc_equiv_start = start
547            self.stc_equiv_pos = start
548
549    def doFindNext(self, start=-1, incremental=False):
550        """Find and highlight the next match in the document.
551       
552        @param start: starting position in text, or -1 to use current position
553       
554        @param incremental: True if an incremental search that will be added to
555        the current search result
556       
557        @return: tuple of two elements.  The first element is the position of
558        the match, -1 if no match, a string indicating that an error occurred.
559        The second element is the position of the original start of the
560        search.  If the search string is invalid, a tuple of (None, None)
561        is returned.
562        """
563        if not self.settings.find:
564            return None, None
565        self.getFlags()
566        if self.regex is None:
567            return _("Incomplete regex"), None
568       
569        self.verifyShadow(start, incremental)
570       
571        match = self.regex.search(self.shadow, self.shadow_equiv_pos)
572        if match:
573            # Because unicode characters are stored as utf-8 in the stc and the
574            # positions in the stc correspond to the raw bytes, not the number
575            # of unicode characters, we have to find out the offset to the
576            # unicode chars in terms of raw bytes.
577            pos = self.stc_equiv_pos + len(self.shadow[self.shadow_equiv_pos:match.start(0)].encode('utf-8'))
578            count = len(self.shadow[match.start(0):match.end(0)].encode('utf-8'))
579            self.stc_equiv_start = pos
580            self.stc_equiv_pos = pos + count
581           
582            self.shadow_equiv_pos = match.end(0)
583           
584            #dprint("match=%s shadow: (%d-%d) equiv=%d, stc: (%d-%d) equiv=%d" % (match.group(0), match.start(0), match.end(0), self.shadow_equiv_pos, pos, pos+count, self.stc_equiv_pos))
585            self.stc.SetSelection(self.stc_equiv_start, self.stc_equiv_pos)
586       
587        else:
588            pos = -1
589       
590        return pos, start
591   
592    def getReplacement(self, replacing):
593        """Extended regex replacement
594       
595        This handles extra regex replacement targets, like upper and lower
596        casing of targets, that the standard python regular expression matcher
597        doesn't include.
598        """
599        def firstLower(s):
600            if len(s) > 1:
601                return s[0].lower() + s[1:]
602            return s.lower()
603       
604        def firstUpper(s):
605            if len(s) > 1:
606                return s[0].upper() + s[1:]
607            return s.upper()
608       
609        def lower(s):
610            return s.lower()
611       
612        def upper(s):
613            return s.upper()
614       
615        match = self.regex.match(replacing)
616        if not match:
617            # Hmmm.  This should have worked because theoretically we should
618            # have been matching a value returned by the same regex.
619            return replacing
620        self.dprint("matches: %s" % str(match.groups()))
621         
622        output = []
623        next_once = None
624        next_until = None
625        parts = re.split("(\\\\(?:[0-9]{1,2}|g<[0-9]+>|l|L|u|U|E))", self.settings.replace)
626        self.dprint(parts)
627        for part in parts:
628            if part.startswith("\\"):
629                escape = part[1:]
630                if escape == "l":
631                    next_once = firstLower
632                elif escape == "u":
633                    next_once = firstUpper
634                elif escape == "L":
635                    next_until = lower
636                elif escape == "U":
637                    next_until = upper
638                elif escape == "E":
639                    next_until = None
640                else:
641                    try:
642                        if escape.startswith("g"):
643                            index = int(part[1:])
644                            self.dprint("found %s: %d" % (escape, index))
645                        else:
646                            index = int(part[1:])
647                            self.dprint("found index %d" % index)
648                        value = match.group(index)
649                        if value:
650                            if next_once:
651                                value = next_once(value)
652                                self.dprint("next_once converted to %s" % value)
653                                next_once = None
654                            elif next_until:
655                                value = next_until(value)
656                                self.dprint("next_until converted to %s" % value)
657                            output.append(value)
658                    except ValueError:
659                        # not an integer means we just insert the value
660                        output.append(part)
661                    except IndexError:
662                        # no matching group with that index, so put no value in the
663                        # output for this match
664                        pass
665                self.dprint("part=%s: once=%s until=%s" % (part, str(next_once), str(next_until)))
666            elif part:
667                if next_once:
668                    part = next_once(part)
669                    self.dprint("converted to %s" % part)
670                    next_once = None
671                elif next_until:
672                    part = next_until(part)
673                    self.dprint("converted to %s" % part)
674                output.append(part)
675        self.dprint("output = %s" % str(output))
676        text = "".join(output)
677        return text
678
679    def doReplace(self):
680        """Replace the selection
681       
682        Replace the current selection with the regex replacement
683        """
684        self.verifyShadow()
685       
686        # We assume that doFindNext has been called, setting up the equivalent
687        # start and end positions
688        replacing = self.stc.GetTextRange(self.stc_equiv_start, self.stc_equiv_pos)
689        replacement = self.getReplacement(replacing)
690       
691        self.stc.SetTargetStart(self.stc_equiv_start)
692        self.stc.SetTargetEnd(self.stc_equiv_pos)
693       
694        # The stc equivalent position must be adjusted for the difference in
695        # numbers of bytes, not numbers of characters.
696        self.stc_equiv_pos += len(replacement.encode('utf-8')) - len(replacing.encode('utf-8'))
697        self.stc.ReplaceTarget(replacement)
698        self.stc.SetSelection(self.stc_equiv_start, self.stc_equiv_pos)
699
700class FindBar(wx.Panel, debugmixin):
701    """Find panel customized from PyPE's findbar.py
702   
703    """
704    debuglevel = 0
705   
706    def __init__(self, parent, frame, stc, storage=None, service=None, direction=1, **kwargs):
707        wx.Panel.__init__(self, parent, style=wx.NO_BORDER|wx.TAB_TRAVERSAL)
708        self.frame = frame
709        self.stc = stc
710        if isinstance(storage, dict):
711            self.storage = storage
712        else:
713            self.storage = {}
714        self.settings = FindSettings()
715        if service:
716            self.service = service(self.stc, self.settings)
717        else:
718            self.service = FindService(self.stc, self.settings)
719       
720        self.createCtrls()
721       
722        # PyPE compat
723        self._lastcall = None
724        self.setDirection(direction)
725   
726    def createCtrls(self):
727        sizer = wx.BoxSizer(wx.HORIZONTAL)
728        self.label = wx.StaticText(self, -1, _(self.service.forward) + u":")
729        sizer.Add(self.label, 0, wx.CENTER)
730        self.find = wx.TextCtrl(self, -1, size=(-1,-1), style=wx.TE_PROCESS_ENTER)
731        sizer.Add(self.find, 1, wx.EXPAND)
732        self.SetSizer(sizer)
733       
734        self.find.Bind(wx.EVT_TEXT_ENTER, self.OnEnter)
735        self.find.Bind(wx.EVT_TEXT, self.OnChar)
736   
737    def setDirection(self, dir=1):
738        if dir > 0:
739            self._lastcall = self.OnFindN
740            text = self.service.forward
741        else:
742            self._lastcall = self.OnFindP
743            text = self.service.backward
744        self.label.SetLabel(_(text) + u":")
745        self.Layout()
746
747    def OnChar(self, evt):
748        #handle updating the background color
749        self.resetColor()
750
751        self.service.setFindString(self.find.GetValue())
752       
753        #search in whatever direction we were before
754        self._lastcall(evt, incremental=True)
755       
756        evt.Skip()
757
758    def OnEnter(self, evt):
759        return self._lastcall(evt)
760   
761    def OnNotFound(self, msg=None):
762        self.find.SetForegroundColour(wx.RED)
763        if msg is None:
764            msg = _("Search string was not found.")
765        self.frame.SetStatusText(msg)
766        self.Refresh()
767
768    def resetColor(self):
769        if self.find.GetForegroundColour() != wx.BLACK:
770            self.find.SetForegroundColour(wx.BLACK)
771            self.Refresh()
772
773    def showLine(self, pos, msg):
774        self.dprint()
775        self.resetColor()
776        self.dprint()
777        self.frame.SetStatusText(msg)
778        self.dprint()
779        line = self.stc.LineFromPosition(pos)
780        self.dprint()
781        if not self.stc.GetLineVisible(line):
782            self.stc.EnsureVisible(line)
783        self.dprint()
784        self.stc.EnsureCaretVisible()
785        self.dprint()
786   
787    def cancel(self, pos_at_end=False):
788        self.resetColor()
789        self.frame.SetStatusText('')
790        if pos_at_end:
791            pos = self.stc.GetSelectionEnd()
792        else:
793            pos = self.stc.GetSelectionStart()
794        self.stc.GotoPos(pos)
795        self.stc.EnsureCaretVisible()
796   
797    def OnFindN(self, evt, allow_wrap=True, help='', interactive=True, incremental=False):
798        self._lastcall = self.OnFindN
799       
800        posn, st = self.service.doFindNext(incremental=incremental)
801        self.dprint("start=%s pos=%s" % (st, posn))
802        if posn is None:
803            self.cancel()
804            return
805        elif not isinstance(posn, int):
806            return self.OnNotFound(posn)
807        elif posn != -1:
808            self.dprint("interactive=%s" % interactive)
809            if interactive:
810                self.showLine(posn, help)
811            self.loop = 0
812            return
813       
814        if allow_wrap and st != 0:
815            posn, st = self.service.doFindNext(0)
816            self.dprint("wrapped: start=%d pos=%d" % (st, posn))
817        self.loop = 1
818       
819        if posn != -1:
820            if interactive:
821                self.showLine(posn, "Reached end of document, continued from start.")
822            return
823       
824        self.dprint("not found: start=%d pos=%d" % (st, posn))
825        self.OnNotFound()
826   
827    def OnFindP(self, evt, allow_wrap=True, help='', incremental=False):
828        self._lastcall = self.OnFindP
829       
830        posn, st = self.service.doFindPrev(incremental=incremental)
831        if posn != -1:
832            self.showLine(posn, help)
833            self.loop = 0
834            return
835       
836        if allow_wrap and st != self.stc.GetTextLength():
837            posn, st = self.service.doFindPrev(self.stc.GetTextLength())
838        self.loop = 1
839       
840        if posn != -1:
841            self.showLine(posn, "Reached start of document, continued from end.")
842            return
843       
844        self.OnNotFound()
845   
846    def repeatLastUserInput(self):
847        """Load the user interface with the last saved user input
848       
849        @return: True if user input was loaded from the storage space
850        """
851        if not self.settings.find_user:
852            self.service.unserialize(self.storage)
853            self.find.ChangeValue(self.settings.find_user)
854            self.find.SetInsertionPointEnd()
855            return True
856        return False
857
858    def repeat(self, direction, service=None):
859        self.resetColor()
860        if service is not None:
861            self.service = service(self.stc, self.settings)
862       
863        if direction < 0:
864            self.setDirection(-1)
865        else:
866            self.setDirection(1)
867       
868        if self.repeatLastUserInput():
869            return
870       
871        # replace doesn't use an automatic search, so make sure that a method
872        # has been specified before calling it
873        if self._lastcall:
874            self._lastcall(None)
875   
876    def saveState(self):
877        self.service.serialize(self.storage)
878
879
880class FindMinibuffer(Minibuffer):
881    """
882    Adapter for PyPE findbar.  Maps findbar callbacks to our stuff.
883    """
884    search_storage = {}
885   
886    def createWindow(self, parent, **kwargs):
887        self.win = FindBar(parent, self.mode.frame, self.mode, self.search_storage, direction=self.action.find_direction, service=self.action.find_service)
888   
889    def getHelp(self):
890        return self.win.service.help
891
892    def repeat(self, action):
893        self.win.SetFocus()
894        self.win.repeat(action.find_direction, action.find_service)
895
896    def focus(self):
897        # When the focus is asked for by the minibuffer driver, set it
898        # to the text ctrl or combo box of the pype findbar.
899        self.win.find.SetFocus()
900   
901    def closePreHook(self):
902        self.win.saveState()
903        self.dprint(self.search_storage)
904
905
906class FindText(MinibufferRepeatAction):
907    name = "Find..."
908    tooltip = "Search for a string in the text."
909    default_menu = ("Edit", -400)
910    icon = "icons/find.png"
911    key_bindings = {'default': "C-F", 'emacs': 'C-S', }
912    minibuffer = FindMinibuffer
913    find_service = FindService
914    find_direction = 1
915
916class FindRegex(MinibufferRepeatAction):
917    name = "Find Regex..."
918    tooltip = "Search for a python regular expression."
919    default_menu = ("Edit", 401)
920    key_bindings = {'emacs': 'C-M-S', }
921    minibuffer = FindMinibuffer
922    find_service = FindRegexService
923    find_direction = 1
924
925class FindWildcard(MinibufferRepeatAction):
926    name = "Find Wildcard..."
927    tooltip = "Search using shell-style wildcards."
928    default_menu = ("Edit", 402)
929    key_bindings = {'emacs': 'C-S-M-S', }
930    minibuffer = FindMinibuffer
931    find_service = FindWildcardService
932    find_direction = 1
933
934class FindPrevText(MinibufferRepeatAction):
935    name = "Find Previous..."
936    tooltip = "Search backwards for a string in the text."
937    default_menu = ("Edit", 402)
938    key_bindings = {'default': "C-S-F", 'emacs': 'C-R', }
939    minibuffer = FindMinibuffer
940    find_service = FindService
941    find_direction = -1
942
943
944
945
946class FakeButton(wx.lib.stattext.GenStaticText):
947    """Simple text label that accepts focus.
948   
949    Used as the key processor for replace.  This label accepts focus but
950    doesn't display any indicator that focus has been taken, so it looks like
951    an ordinary label.  However, events can be processed through this label
952    and it is used to handle the keyboard commands for replacing.
953    """
954    def AcceptsFocus(self):
955        return True
956
957class ReplaceBar(FindBar):
958    """Replace panel in the style of the Emacs replace function
959   
960    The control of the minibuffer is similar to emacs:
961   
962    Type the search string in 'Replace:' and hit enter.  Type the replacement
963    string in 'with:' and hit enter.  Then, use the keyboard to control the
964    replacements:
965   
966    'y' or SPACE: replace one match and advance to the next match
967    ',': replace the current match but not advance to the next match
968    'n' or DELETE: skip to the next match
969    'p' or '^': move to the previous match
970    'q': quit matching and remove the replace bar
971    '.': replace one match and quit matching
972    '!': replace all remaining matches and quit matching
973    """
974    help_status = "y: replace, n: skip, q: exit, !:replace all, f: edit find, r: edit replace, ?: help"
975   
976    def __init__(self, *args, **kwargs):
977        FindBar.__init__(self, *args, **kwargs)
978       
979        if 'on_exit' in kwargs:
980            self.on_exit = kwargs['on_exit']
981        else:
982            self.on_exit = None
983       
984        # Count of number of replacements
985        self.count = 0
986       
987        # Last cursor position, tracked during replace all
988        self.last_cursor = 0
989       
990        # focus tracker to make sure that focus events come from us
991        self.focus_tracker = None
992   
993    def createCtrls(self):
994        text = self.service.forward
995        grid = wx.GridBagSizer(2, 2)
996        self.label = wx.StaticText(self, -1, _(text) + u":")
997        grid.Add(self.label, (0, 0), flag=wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT)
998        self.find = wx.TextCtrl(self, -1, size=(-1,-1), style=wx.TE_PROCESS_ENTER)
999        grid.Add(self.find, (0, 1), flag=wx.EXPAND)
1000        self.replace = wx.TextCtrl(self, -1, size=(-1,-1), style=wx.TE_PROCESS_ENTER)
1001        grid.Add(self.replace, (1, 1), flag=wx.EXPAND)
1002       
1003        # Windows doesn't process char events through a real button, so we use
1004        # this fake button as a label and process events through it.  It's
1005        # added here so that it will be last in the tab order.
1006        self.command = FakeButton(self, -1, _("Replace with") + u":")
1007        grid.Add(self.command, (1, 0), flag=wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT)
1008       
1009        grid.AddGrowableCol(1)
1010        self.SetSizer(grid)
1011       
1012        self.find.Bind(wx.EVT_TEXT_ENTER, self.OnTabToReplace)
1013        self.find.Bind(wx.EVT_SET_FOCUS, self.OnFindSetFocus)
1014        self.replace.Bind(wx.EVT_TEXT_ENTER, self.OnTabToCommand)
1015        self.replace.Bind(wx.EVT_KILL_FOCUS, self.OnReplaceLoseFocus)
1016        self.replace.Bind(wx.EVT_SET_FOCUS, self.OnReplaceSetFocus)
1017        self.command.Bind(wx.EVT_BUTTON, self.OnReplace)
1018        self.command.Bind(wx.EVT_KEY_DOWN, self.OnCommandKeyDown)
1019        self.command.Bind(wx.EVT_CHAR, self.OnCommandChar)
1020        self.command.Bind(wx.EVT_SET_FOCUS, self.OnSearchStart)
1021   
1022    def OnReplaceError(self, msg=None):
1023        self.replace.SetForegroundColour(wx.RED)
1024        self.frame.SetStatusText(msg)
1025        self.Refresh()
1026
1027    def resetColor(self):
1028        if self.replace.GetBackgroundColour() != wx.WHITE:
1029            self.replace.SetBackgroundColour(wx.WHITE)
1030            self.replace.Refresh()
1031        FindBar.resetColor(self)
1032
1033    def setDirection(self, dir=1):
1034        self.label.SetLabel(_(self.service.forward) + u":")
1035        self.Layout()
1036
1037    def OnFindSetFocus(self, evt):
1038        self.dprint(evt.GetWindow())
1039        self.cancel()
1040        self.frame.SetStatusText("Enter search text and press Return")
1041   
1042    def OnReplaceSetFocus(self, evt):
1043        self.dprint(evt.GetWindow())
1044        self.frame.SetStatusText("Enter replacement text and press Return")
1045        self.focus_tracker = True
1046   
1047    def OnReplaceLoseFocus(self, evt):
1048        self.dprint(evt.GetWindow())
1049        # Using tab after changing color when a selection exists in
1050        # self.replace causes the text to appear white on a white background.
1051        # Force the selection to disappear when tabbing off of self.replace
1052        self.replace.SetInsertionPointEnd()
1053   
1054    def OnTabToFind(self, evt):
1055        self.find.SetFocus()
1056   
1057    def OnTabToReplace(self, evt):
1058        self.replace.SetFocus()
1059   
1060    def OnTabToCommand(self, evt):
1061        # Using tab after changing color when a selection exists in
1062        # self.replace causes the text to appear white on a white background.
1063        # Force the selection to disappear when tabbing off of self.replace
1064        self.replace.SetInsertionPointEnd()
1065        self.command.SetFocus()
1066   
1067    def OnSearchStart(self, evt):
1068        if self.focus_tracker:
1069            self.service.setFindString(self.find.GetValue())
1070            self.service.setReplaceString(self.replace.GetValue())
1071            self.OnFindN(evt, help=self.help_status)
1072            self.focus_tracker = False
1073   
1074    def OnReplace(self, evt, find_next=True, interactive=True):
1075        """Replace the selection
1076       
1077        The bulk of this algorithm is from PyPE.
1078        """
1079        if self.stc.GetReadOnly():
1080            return False
1081
1082        allow_wrap = interactive
1083        sel = self.stc.GetSelection()
1084        if sel[0] == sel[1]:
1085            if find_next:
1086                self.OnFindN(None, allow_wrap=allow_wrap, help=self.help_status, interactive=interactive)
1087            return True
1088       
1089        try:
1090            self.service.doReplace()
1091            if find_next:
1092                self.OnFindN(None, allow_wrap=allow_wrap, help=self.help_status, interactive=interactive)
1093            self.count += 1
1094               
1095            return True
1096        except ReplacementError, e:
1097            self.OnReplaceError(str(e))
1098        return False
1099   
1100    def OnReplaceAll(self, evt):
1101        self.count = 0
1102        self.loop = 0
1103        self.last_cursor = 0
1104       
1105        # FIXME: this takes more time than it should, probably because there's
1106        # a lot of redundant stuff going on in OnReplace.  This should be
1107        # rewritten for speed at some point.
1108        if hasattr(self.stc, 'showBusy'):
1109            self.stc.showBusy(True)
1110            wx.Yield()
1111        self.stc.BeginUndoAction()
1112        valid = True
1113        try:
1114            while self.loop == 0 and valid:
1115                valid = self.OnReplace(None, interactive=False)
1116            self.stc.GotoPos(self.last_cursor)
1117        finally:
1118            self.stc.EndUndoAction()
1119       
1120        if hasattr(self.stc, 'showBusy'):
1121            self.stc.showBusy(False)
1122        if valid:
1123            if self.count == 1:
1124                occurrences = _("Replaced %d occurrence")
1125            else:
1126                occurrences = _("Replaced %d occurrences")
1127            self.OnExit(msg=_(occurrences) % self.count)
1128   
1129    def OnCommandKeyDown(self, evt):
1130        key = evt.GetKeyCode()
1131        mods = evt.GetModifiers()
1132       
1133        # FIXME: windows doesn't receive the return character, so this code is
1134        # never reached when the return key is pressed.
1135       
1136        #dprint("key=%s mods=%s" % (key, mods))
1137        if key == wx.WXK_TAB and not mods & (wx.MOD_CMD|wx.MOD_SHIFT|wx.MOD_ALT):
1138            self.find.SetFocus()
1139        elif key == wx.WXK_RETURN or key == wx.WXK_DELETE:
1140            self.OnFindN(None, help=self.help_status)
1141        else:
1142            evt.Skip()
1143   
1144    def OnCommandChar(self, evt):
1145        uchar = unichr(evt.GetKeyCode())
1146        #dprint("uchar = %s" % uchar)
1147        if uchar in u'nN':
1148            self.OnFindN(None, help=self.help_status)
1149        elif uchar in u'yY ':
1150            self.OnReplace(None)
1151        elif uchar in u'pP^':
1152            self.OnFindP(None, help=self.help_status)
1153        elif uchar in u'qQ':
1154            self.OnExit()
1155        elif uchar in u'.':
1156            self.OnReplace(None, find_next=False)
1157            self.OnExit()
1158        elif uchar in u',':
1159            self.OnReplace(None, find_next=False)
1160        elif uchar in u'!':
1161            self.OnReplaceAll(None)
1162        elif uchar in u'fF':
1163            self.OnTabToFind(None)
1164        elif uchar in u'rR':
1165            self.OnTabToReplace(None)
1166        elif uchar in u'?':
1167            Publisher().sendMessage('peppy.log.info', (self.frame, self.__doc__))
1168        else:
1169            evt.Skip()
1170   
1171    def OnExit(self, msg=''):
1172        self.cancel(pos_at_end=True)
1173        if msg:
1174            self.frame.SetStatusText(msg)
1175        if self.on_exit:
1176            self.on_exit()
1177
1178    def repeatLastUserInput(self):
1179        """Load the user interface with the last saved user input
1180       
1181        @return: True if user input was loaded from the storage space
1182        """
1183        # disable the repeat search when using the replace buffer
1184        self._lastcall = None
1185       
1186        # Set the focus to the find string to prevent the focus from staying on
1187        # the command control.  Unless this is done, the focus tends to stay
1188        # on the command control which highlights the last successful search.
1189        self.find.SetFocus()
1190       
1191        if not self.settings.find_user:
1192            self.service.unserialize(self.storage)
1193            self.find.ChangeValue(self.settings.find_user)
1194            self.find.SetInsertionPointEnd()
1195            self.replace.ChangeValue(self.settings.replace_user)
1196            self.replace.SetInsertionPointEnd()
1197            return True
1198        return False
1199
1200
1201class ReplaceMinibuffer(FindMinibuffer):
1202    """
1203    Adapter for PyPE findbar.  Maps findbar callbacks to our stuff.
1204    """
1205    search_storage = {}
1206   
1207    def createWindow(self, parent, **kwargs):
1208        self.win = ReplaceBar(parent, self.mode.frame, self.mode,
1209                              self.search_storage, on_exit=self.removeFromParent,
1210                              direction=self.action.find_direction,
1211                              service=self.action.find_service)
1212
1213
1214
1215class Replace(MinibufferRepeatAction):
1216    name = "Replace..."
1217    tooltip = "Replace a string in the text."
1218    default_menu = ("Edit", 410)
1219    icon = "icons/text_replace.png"
1220    key_bindings = {'win': 'C-H', 'emacs': 'F6', }
1221    minibuffer = ReplaceMinibuffer
1222    find_service = FindService
1223    find_direction = 1
1224
1225class ReplaceRegex(MinibufferRepeatAction):
1226    name = "Replace Regex..."
1227    tooltip = "Replace using python regular expressions."
1228    default_menu = ("Edit", 411)
1229    key_bindings = {'emacs': 'S-F6', }
1230    minibuffer = ReplaceMinibuffer
1231    find_service = FindRegexService
1232    find_direction = 1
1233
1234class ReplaceWildcard(MinibufferRepeatAction):
1235    name = "Replace Wildcard..."
1236    tooltip = "Replace using shell-style wildcards."
1237    default_menu = ("Edit", 411)
1238    key_bindings = {'emacs': 'M-F6', }
1239    minibuffer = ReplaceMinibuffer
1240    find_service = FindWildcardService
1241    find_direction = 1
1242
1243
1244class CaseSensitiveSearch(ToggleAction):
1245    """Should search string require match by case"""
1246    name = "Case Sensitive Search"
1247    default