Implementing Search and Replace functionality in a GtkSource widget

I am trying to implement a simple Search and Replace functionality in a Gtk.SourceView widget.
As expected, when the user clicks on the Find Next button:

  • ALL the matches should be highlighted (possibly the current match should have a different color)
  • then the user should be able to cycle (forward/backward) through the results (and replace them if he wants)

This is the relevant documentation that I managed to find.

GtkSource Documentation

GtkSource 3: Main - SearchContext Lazka (I am on Linux Mint 18.3 with Gtk 3.18.9)
(GtkSource 4: Main - SearchContext Lazka - SearchContext Gnome)

Based on this documentation, I wrote this code (dummie code to understand the functionality):

    def on_srFindNextBtn_clicked(self, widget):
        srSourceBuffer = self.sourceview.get_buffer()
        srCursorMark = srSourceBuffer.get_insert()
        srCursorIter = srSourceBuffer.get_iter_at_mark(srCursorMark)
        srSearchSettings = GtkSource.SearchSettings.new()
        srSearchSettings.set_search_text('ipsum')
        srSearchContext = GtkSource.SearchContext.new(srSourceBuffer, srSearchSettings)
        srSearchContext.set_highlight(True)

        success, match_start, match_end = srSearchContext.forward(srCursorIter)

        if success:
            occurencePos = srSearchContext.get_occurrence_position(match_start, match_end)
            print(occurencePos)
            resultsNum = srSearchContext.get_occurrences_count()
            print('Total occurences: ' + str(resultsNum))
        else:
            print('Forward search: False')

The first time I click on the Find Next button I get this result:

1
Total occurences: -1

Questions:

  • I guess that the search stops after the first match, right?
  • so the get_occurances_count() method return -1 because the buffer is not already fully scanned, right?
  • should I manually implement a loop to call the forward() method until I reach the end of the buffer? But then, how can I cycle through the results?
  • Why the match found doesn’t get highlighted (even if the default theme’s scheme ‘match-style’ is correctly set)?

OPTIONAL INFORMATION

On another note, the following is an example of a search function with a TextView (not a SourceView). I got the loop idea from there.

MULTILINE TEXT EDITOR
Example from the documentation (https://python-gtk-3-tutorial.readthedocs.io/en/latest/textview.html).
Here they make use of the Gtk.TextIter.forware_search() method (https://lazka.github.io/pgi-docs/Gtk-3.0/classes/TextIter.html#Gtk.TextIter.forward_search).

def on_search_clicked(self, widget):
        dialog = SearchDialog(self)
        response = dialog.run()
        if response == Gtk.ResponseType.OK:
            cursor_mark = self.textbuffer.get_insert()
            start = self.textbuffer.get_iter_at_mark(cursor_mark)
            if start.get_offset() == self.textbuffer.get_char_count():
                start = self.textbuffer.get_start_iter()

            self.search_and_mark(dialog.entry.get_text(), start)

        dialog.destroy()

    def search_and_mark(self, text, start):
        end = self.textbuffer.get_end_iter()
        match = start.forward_search(text, 0, end)

        if match is not None:
            match_start, match_end = match
            self.textbuffer.apply_tag(self.tag_found, match_start, match_end)
            self.search_and_mark(text, match_end)

So, can anyone explain how I should go about implementing a search and replace functionality in a GtkSource widget?

1 Like

The GtkSourceSearchContext API is asynchronous.

There are some code examples in the tests/ directory of GtkSourceView, but it’s in C, not Python.

Here is some shortened code I used for a small project of mine:

	self.search_context = GtkSource.SearchContext.new(self.source_buffer, None)
	self.search_settings = self.search_context.get_settings()
	self.search_settings.set_wrap_around (True)
	self.search_mark = Gtk.TextMark()

def find_entry_search_changed (self, search_entry):
	search_text = search_entry.get_text()
	self.search_settings.set_search_text(search_text)
	self.search_mark = self.source_buffer.get_insert()
	self.search_forward ()

def find_menuitem_activated (self, menuitem):
	self.search_grid.set_visible(True)
	self.builder.get_object('searchentry2').set_visible(False)
	self.builder.get_object('button15').set_visible(False)
	self.builder.get_object('button16').set_visible(False)
	result = self.source_buffer.get_selection_bounds()
	search_entry = self.builder.get_object('searchentry1')
	if len(result) == 2:
		start_iter, end_iter = result[0], result[1]
		search_text = self.source_buffer.get_text(start_iter, end_iter, True)
		# next line will trigger find_entry_search_changed 
		search_entry.set_text(search_text)
	search_entry.grab_focus()

def find_and_replace_menuitem_activated (self, menuitem):
	result = self.source_buffer.get_selection_bounds()
	search_entry = self.builder.get_object('searchentry1')
	if len(result) == 2:
		start_iter, end_iter = result[0], result[1]
		search_text = self.source_buffer.get_text(start_iter, end_iter, True)
		# next line will trigger find_entry_search_changed 
		search_entry.set_text(search_text)
	self.search_grid.set_visible(True)
	self.builder.get_object('searchentry2').set_visible(True)
	self.builder.get_object('button15').set_visible(True)
	self.builder.get_object('button16').set_visible(True)

def find_forward_button_clicked (self, button):
	self.search_forward ()

def search_forward (self):
	search_iter = self.source_buffer.get_iter_at_mark (self.search_mark)
	search_iter.forward_char()    #advance the search by one char
	result = self.search_context.forward(search_iter)
	valid, start_iter, end_iter = result[0], result[1], result[2]
	if valid == True:
		self.source_buffer.move_mark(self.search_mark, end_iter)
		self.source_buffer.select_range(start_iter, end_iter)
		self.source_view.scroll_to_iter(end_iter, 0.1, True, 0.0, 0.5)

def find_backward_button_clicked (self, button):
	search_iter = self.source_buffer.get_iter_at_mark (self.search_mark)
	result = self.search_context.backward(search_iter)
	valid, start_iter, end_iter = result[0], result[1], result[2]
	if valid == True: 
		self.source_buffer.move_mark(self.search_mark, start_iter)
		self.source_buffer.select_range(start_iter, end_iter)
		self.source_view.scroll_to_iter(start_iter, 0.1, True, 0.0, 0.5)

def find_replace_button_clicked (self, button):
	result = self.source_buffer.get_selection_bounds()
	if len(result) == 2 :
		start_iter, end_iter = result[0], result[1]
		replace = self.builder.get_object('searchentry2').get_text()
		length = len(replace)
		self.source_buffer.move_mark(self.search_mark, end_iter)
		self.search_context.replace(start_iter, end_iter, replace, length)
		self.search_forward()

def find_replace_all_button_clicked (self, button):
	result = self.source_buffer.get_selection_bounds()
	if len(result) == 2 :
		start_iter, end_iter = result[0], result[1]
		replace = self.builder.get_object('searchentry2').get_text()
		length = len(replace)
		self.source_buffer.move_mark(self.search_mark, end_iter)
		self.search_context.replace_all(replace, length)

You can find the full text in https://github.com/benreu/gremlin/blob/master/src/gremlin.py. User beware, it isn’t pretty. :grimacing:

1 Like

Thank you very much for your valuable contribution. It is a nice example of synchronous search functionality. I’m sure it will be of great help for a lot of people.

@swilmet
As per your suggestion, I looked at the code examples in the tests/ directory of GtkSourceView and tried to convert them from C to Python. I’m not yet finished, but I already have a working code. I will share the solution once I’m done (with asynchronous functionality).
P.s. On a side note, I was pretty surprised when I couldn’t find a tutorial on the web. It is a basic functionality, used by a lot of softwares/developers. It is strange that no one ever posted a guide/explanation on the subject.

Hello everyone!! After a lot of work, extensive research and an “I-lost-track” amount of tests, I am happy to share with you a working implementation of the “Find Next” functionality (only relevant methods are reported below, not a full application).
P.s. “Find Previous”, “Replace” etc functions should be relatively easy to implement starting from this.

    def on_srFindNextBtn_clicked(self, widget):
        searchText = self.srSearchEntry.get_text()
        searchText = GtkSource.utils_escape_search_text(searchText)

        srSourceBuffer = self.sourceview.get_buffer()

        if len(searchText) > 0:

            if not self.srSearchContext:
                self.srSearchContext = GtkSource.SearchContext.new(srSourceBuffer, None)
                print("SearchContext created")
                self.srSearchContext.connect('notify::occurrences-count', self.on_notify_occurences_count_property_set)

            srSearchSettings = self.srSearchContext.get_settings()

            if not srSearchSettings.get_search_text() or len(srSearchSettings.get_search_text()) == 0 or searchText != srSearchSettings.get_search_text():
                print("Setting srSearchSettings.searchText")
                srSearchSettings.set_search_text(searchText)

            bounds = srSourceBuffer.get_selection_bounds()
            if len(bounds) != 0:
                start, end = bounds
            else:
                start = None
                srCursorMark = srSourceBuffer.get_insert()
                srCursorIter = srSourceBuffer.get_iter_at_mark(srCursorMark)
                cursorOffset = srCursorIter.get_offset()
                if cursorOffset == srSourceBuffer.get_char_count():
                    end = srSourceBuffer.get_start_iter()
                else:
                    end = srCursorIter

            print("Starting async forward search")
            # GtkSource.SearchContext.forward_async(Gtk.TextIter, Gio.Cancellable or None, Gio.AsyncReadyCallback, *user_data)
            self.srSearchContext.forward_async(end, None, self.forwardSearchFinished)

    # Gio.AsyncReadyCallback(GObject.Object or None, Gio.AsyncResult, *user_data)
    def forwardSearchFinished(self, sourceObject, asyncResult):
        success, matchStart, matchEnd = self.srSearchContext.forward_finish(asyncResult)
        if success:
            self.selectSearchOccurence(matchStart, matchEnd)

    def selectSearchOccurence(self, matchStart, matchEnd):
        srSourceView = self.sourceview
        srSourceBuffer = srSourceView.get_buffer()
        srSourceBuffer.select_range(matchStart, matchEnd)
        insert = srSourceBuffer.get_insert()
        srSourceView.scroll_mark_onscreen(insert)

        if self.idleUpdateLabelId == 0:
            self.idleUpdateLabelId = GLib.idle_add(self.updateLabelIdleCb)  # GLib.PRIORITY_DEFAULT_IDLE


    def updateLabelIdleCb(self):
        print("Idle process called")
        self.idleUpdateLabelId = 0
        self.updateLabelOccurrences()
        return GLib.SOURCE_REMOVE

    def updateLabelOccurrences (self):
        srSourceBuffer = self.sourceview.get_buffer()
        srSearchContext = self.srSearchContext
        srSearchSettings = srSearchContext.get_settings()

        if srSearchSettings.get_search_text():
            occurrencesCount = srSearchContext.get_occurrences_count()

            if occurrencesCount == -1:
                text = ""

            elif occurrencesCount == 0:
                text = "No match found"

            else:
                bounds = srSourceBuffer.get_selection_bounds()
                if len(bounds) != 0:
                    selectStart, selectEnd = bounds

                    occurrencePos = srSearchContext.get_occurrence_position(selectStart, selectEnd)

                    if occurrencePos == -1:
                        text = str(occurrencesCount) + " occurences"
                    else:
                        text = str(occurrencePos) + " of " + str(occurrencesCount) + " occurences"

                else:
                    text = str(occurrencesCount) + " occurences"

        else:
            text = ""

        self.srResNumLabel.set_text(text)

    def on_notify_occurences_count_property_set(self, object, pspec):
        print("Notify occurences count triggered - Occurences: " + str(self.srSearchContext.get_occurrences_count()))
        self.updateLabelOccurrences()
1 Like