Speeding KMWriter Up

If you noticed when we added grammar checking, the editor became close to unusable. Every change triggered the grammar checker. The grammar checker blocked input until the entire document was processed.

Blocking input? Updating UI?… oh no… Are we covering threads and the main event loop?

Let’s Talk

With multiple threads, we need a way for them to communicate. Vala provides us with a Concurrent List and Concurrent Set1. What should we stick in the list?

If I were getting my summer home painted (I don’t have a summer home yet), I’d want to make sure they paint the right house. I would give the painters an address, color, and description of the house.

For updating a GTK.TextView from another thread it could look like:

  • Offset - The address
  • TextTag - The color
  • Text at Offset at time of Processing - Description of the House

Part of the issue in this analogy is my summer home is a mobile home2. I want to capture the cursor at time of the request as well.

The list structure can look like:

public class TagUpdateRequest {
    public int cursor_offset;
    public int text_offset;
    public string text;
    public TextTag tag;
}

Let’s Shred, or Thread?

We separated our Grammar code into its own class. We can add some members for communication.

private Gee.ConcurrentSet<TagUpdateRequest> send_to_processor;
private Gee.ConcurrentSet<TagUpdateRequest> send_to_buffer;

The send_to_processor contains data we want to grammar check. send_to_buffer contains data to update in the buffer.

To initialize these, we can do:

send_to_processor = new Gee.ConcurrentSet<TagUpdateRequest> (TagUpdateRequest.compare_tag_requests);
send_to_buffer = new Gee.ConcurrentSet<TagUpdateRequest> (TagUpdateRequest.compare_tag_requests);

where TagUpdateRequest.compare_tag_requests is a function that checks if all the fields are equal. If they are all equal, it returns 0, otherwise it returns -1.

Let’s populate send_to_processor.

private void check_grammar () {
    if (!grammar_timer.can_do_action ()) {
        return;
    }
    TextIter buffer_start, buffer_end, cursor_location;
    buffer.get_bounds (out buffer_start, out buffer_end);
    buffer.remove_tag (grammar_error, buffer_start, buffer_end);
    var cursor = buffer.get_insert ();
    buffer.get_iter_at_mark (out cursor_location, cursor);

    TextIter sentence_start = buffer_start.copy ();
    TextIter sentence_end = buffer_start.copy ();
    while (sentence_end.forward_sentence_end ()) {
        string sentence = source_buffer.get_text (sentence_start, sentence_end, true);
        if (!cursor_location.in_range (sentence_start, sentence_end)) {
            TagUpdateRequest request = new TagUpdateRequest () {
                cursor_offset = cursor_location.get_offset (),
                text_offset = sentence_start.get_offset (),
                text = sentence,
                tag = grammar_error
            };
            send_to_processor.add (request);
        }
        sentence_start = sentence_end;
    }
}

Now we have a set of work to do but no worker. Let’s create one.

private void start_worker () {
    processor_check.lock ();
    if (!processor_running) {
        if (grammar_processor != null) {
            grammar_processor.join ();
        }

        grammar_processor = new Thread<void> ("grammar-processor", process_grammar);
        processor_running = true;
    }
    processor_check.unlock ();
} 

start_worker takes a lock on the processor_check Mutex. This makes it so only 1 scheduler thread at a time can access the processor_running variable or spawn a new thread.

We check to see if grammar_processor is not null in case we should wait for any existing worker to exit prior to starting a new one.

new Thread creates a new thread that will run process_grammar. Let’s create process_grammar.

private void process_grammar () {
    while (send_to_processor.size != 0) {
        TagUpdateRequest requested = send_to_processor.first ();
        send_to_processor.remove (requested);
        string sentence = strip_markdown (requested.text).chug ().chomp ();
        if (!grammar_correct_sentence_check (sentence)) {
            send_to_buffer.add (requested);
        }
    }
    processor_running = false;
    Thread.exit (0);
    return;
}

Here, we take the request from the processor list, convert the raw buffer text into a markdownless sentence, and check the grammar. If it’s not grammatically valid, we send it back to the buffer. At the end of the function, we change the bool to false. Then we call Thread.exit (0) to signal the thread has terminated. This will allow start_worker to detect and start a new worker if needed.

Updating the Buffer

private bool update_buffer () {
    TextIter buffer_start, buffer_end, cursor_location;
    var cursor = buffer.get_insert ();
    buffer.get_iter_at_mark (out cursor_location, cursor);

    buffer.get_bounds (out buffer_start, out buffer_end);
    while (send_to_buffer.size != 0) {
        TagUpdateRequest requested = send_to_buffer.first ();
        send_to_buffer.remove (requested);

        // Check at the offset in the request
        TextIter check_start, check_end;
        buffer.get_iter_at_offset (out check_start, requested.text_offset);
        buffer.get_iter_at_offset (out check_end, requested.text_offset + requested.text.length);
        if (check_start.in_range (buffer_start, buffer_end) && 
            check_end.in_range (buffer_start, buffer_end) && 
            check_start.get_text (check_end) == requested.text)
        {
            buffer.apply_tag (requested.tag, check_start, check_end);
            continue;
        }

        int cursor_change = cursor_location.get_offset () - requested.cursor_offset;
        if (check_start.forward_chars (cursor_change)) {
            buffer.get_iter_at_offset (out check_end, check_start.get_offset () + requested.text.length);
            if (check_start.in_range (buffer_start, buffer_end) && 
                check_end.in_range (buffer_start, buffer_end) && 
                check_start.get_text (check_end) == requested.text)
            {
                buffer.apply_tag (requested.tag, check_start, check_end);
                continue;
            }
        }
    }

    return true;
}

This is a lot of code. We ask the buffer to give us the text at the original offset in the request. If the text matches, we apply the tag. No match, we check to see where the cursor moved to, and adjust the offset from the request the same amount. If the text matches, we apply the tag.

If we could not update, we abandon the request. We could scan to see if we could match the string, but it would take to long. It is also possible another pending request will be in the right location.

We only want this code to run when the user isn’t typing, or when the main thread is idle. To do this, we can call GLib.Idle.add with our update_buffer. We want to add this when our Grammar object is attached to the view.

Piecing it Together

In our grammar class, we can create an attach method.

public bool attach (TextView textview) {
    // Validate the view and buffer exist
    if (textview == null) {
        return false;
    }

    view = textview;
    buffer = view.get_buffer ();
    if (buffer == null) {
        view = null;
        return false;
    }

    buffer.changed.connect (check_grammar);

    // Grammar Styles
    grammar_error = buffer.create_tag ("grammar-error");
    grammar_error.background = "#00a367";
    grammar_error.background_set = true;
    grammar_error.foreground = "#eeeeee";
    grammar_error.foreground_set = true;

    GLib.Idle.add (update_buffer);

    return true;
}

This will create the tags in our buffer and register for the appropriate events.

Back in our activate method (where we contructed the UI), we can connect the new Grammar class to the view.

// Attach grammar checker
Grammar grammar_enrichment = new Grammar ();
grammar_enrichment.attach (source_view);

When we run the application, we see a usable text editor with a bit of flickering.

Live Grammar

I can’t give all my secrets away, or my markdown editor wouldn’t be worth stealing. With some caching or additional TagUpdateRequest members, you could fix the flickering.

You might notice if you close the window, the application keeps running. We need to connect to the Window Closed event and terminate pending threads.

In our activate method, we can connect to the window_removed event to signal to our grammar to detach.

window_removed.connect (() => {
    grammar_enrichment.detach ();
    markdown_enrichment.detach ();
});

And our Grammar’s detach method can look like:

public void detach () {
    // Disconnect from events
    buffer.changed.disconnect (check_grammar);

    buffer = null;
    view = null;

    // Drain queues
    while (send_to_buffer.size != 0) {
        TagUpdateRequest requested = send_to_buffer.first ();
        send_to_buffer.remove (requested);
    }
    while (send_to_processor.size != 0) {
        TagUpdateRequest requested = send_to_processor.first ();
        send_to_processor.remove (requested);
    }
    if (grammar_processor != null) {
        grammar_processor.join ();
    }
}

Boom. Gtk TextView with live Grammar checking without halting during input.

🎤-drop.

If you like posts like these and would like to encourage the development of ThiefMD. Please consider becoming a sponsor.

Live Grammar

The code is available on Github.com/kmwallio/kmwriter.

  1. A set can have only one occurrence of a value. A list can have multiple occurrences of the same value. {1, 2} is a set and a list. {1, 1, 2} is not a set but is still a list. 

  2. Mobile homes aren’t bad. The fact the location can change complicates the problem.