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.
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.
The code is available on Github.com/kmwallio/kmwriter.