gtk-rs: how to update view from another thread
Asked Answered
E

1

6

I am creating a UI application with gtk-rs. In that application, I have to spawn a thread to continuously communicate with another process. Sometimes, I have to update the UI based on what happens in that thread. But, I'm not sure how to do this because I am not able to hold a reference to any part of the UI across threads.

Here is the code I tried:

use gtk;

fn main() {
    let application =
        gtk::Application::new(Some("com.github.gtk-rs.examples.basic"), Default::default()).unwrap()

    application.connect_activate(|app| {
        let ui_model = build_ui(app);
        setup(ui_model);
    });

    application.run(&[]);
}

struct UiModel { main_buffer: gtk::TextBuffer }

fn build_ui(application: &gtk::Application) -> UiModel {
    let glade_src = include_str!("test.glade");
    let builder = gtk::Builder::new();
    builder
        .add_from_string(glade_src)
        .expect("Couldn't add from string");

    let window: gtk::ApplicationWindow = builder.get_object("window").unwrap();
    window.set_application(Some(application));
    window.show_all();

    let main_text_view: gtk::TextView = builder.get_object("main_text_view")

    return UiModel {
        main_buffer: main_text_view.get_buffer().unwrap(),
    };
}

fn setup(ui: UiModel) {
    let child_process = Command::new("sh")
        .args(&["-c", "while true; do date; sleep 2; done"])
        .stdout(Stdio::piped())
        .spawn()
        .unwrap();

    let incoming = child_process.stdout.unwrap();

    std::thread::spawn(move || {                              // <- This is the part to pay
        &BufReader::new(incoming).lines().for_each(|line| {   //    attention to.
            ui.main_buffer.set_text(&line.unwrap());          //    I am trying to update the
        });                                                   //    UI text from another thread.
    });
}

But, I get the error:

    |       std::thread::spawn(move || {
    |  _____^^^^^^^^^^^^^^^^^^_-
    | |     |
    | |     `*mut *mut gtk_sys::_GtkTextBufferPrivate` cannot be sent between threads safely

This makes sense. I can understand that the Gtk widgets aren't thread safe. But then how do I update them? Is there a way to send signals to the UI thread safely? or is there a way to run the .lines().for_each( loop in the same thread in a way that does not block the UI?

Whatever solution I go with will have to be very high performance. I will be sending much more data than in the example and I want a very low latency screen refresh.

Thanks for your help!

Emaciated answered 6/3, 2021 at 20:35 Comment(3)
I haven't done this in Rust so some general hand-wavey advice: If you have a thread working fast and with a lot of data, you probably can't easily create a snapshot of data for the GUI. So be sure you can lock it in chunks so the work thread is not blocked too often. Then I'd do what you need to do in the GUI to only read data for items actually on screen. Don't create lists or table views of all the data. Create a view with a scrollbar that displays a blur while scrolling and then does callbacks to pull data for display for the 20 things actually viewable. This is a control option somewhere.Cleromancy
Oh and here: gtk-rs.org/docs/gtk/#threadsCleromancy
to update UI from a thread you have to use g_idle_add() developer.gnome.org/glib/stable/… .. (this is the c doc)Neruda
E
6

Ok, I solved the problem. For anyone in the future, here is the solution.

glib::idle_add(|| {}) lets you run a closure from another thread on the UI thread (thansk @Zan Lynx). This would be enough to solve the thread safety issue, but it's not enough to get around the borrow checker. No GTKObject is safe to send between threads, so another thread can never even hold a reference to it, even if it will never use it. So you need to store the UI references globally on the UI thread and set up a communication channel between threads. Here is what I did step by step:

  1. Create a way to send data between threads that does not involve passing closures. I used std::sync::mpsc for now but another option might be better long-term.
  2. Create some thread-local global storage. Before you ever start the second thread, store your UI references and the receiving end of that communication pipeline globally on the main thread.
  3. Pass the sending end of the channel to the second thread via a closure. Pass the data you want through that sender.
  4. After passing the data through, use glib::idle_add() -- not with a closure but with a static function -- to tell the UI thread to check for a new message in the channel.
  5. In that static function on the UI thread, access your global UI and receiver variables and update the UI.

Thanks to this thread for helping me figure that out. Here is my code:

extern crate gio;
extern crate gtk;
extern crate pango;

use gio::prelude::*;
use gtk::prelude::*;
use std::cell::RefCell;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::mpsc;

fn main() {
    let application =
        gtk::Application::new(Some("com.github.gtk-rs.examples.basic"), Default::default())
            .unwrap();

    application.connect_activate(|app| {
        let ui_model = build_ui(app);
        setup(ui_model);
    });

    application.run(&[]);
}

struct UiModel {
    main_buffer: gtk::TextBuffer,
}

fn build_ui(application: &gtk::Application) -> UiModel {
    let glade_src = include_str!("test.glade");
    let builder = gtk::Builder::new();
    builder
        .add_from_string(glade_src)
        .expect("Couldn't add from string");

    let window: gtk::ApplicationWindow = builder.get_object("window").unwrap();
    window.set_application(Some(application));
    window.show_all();

    let main_text_view: gtk::TextView = builder.get_object("main_text_view").unwrap();

    return UiModel {
        main_buffer: main_text_view.get_buffer().unwrap(),
    };
}

fn setup(ui: UiModel) {
    let (tx, rx) = mpsc::channel();
    GLOBAL.with(|global| {
        *global.borrow_mut() = Some((ui, rx));
    });
    let child_process = Command::new("sh")
        .args(&["-c", "while true; do date; sleep 2; done"])
        .stdout(Stdio::piped())
        .spawn()
        .unwrap();

    let incoming = child_process.stdout.unwrap();

    std::thread::spawn(move || {
        &BufReader::new(incoming).lines().for_each(|line| {
            let data = line.unwrap();
            // send data through channel
            tx.send(data).unwrap();
            // then tell the UI thread to read from that channel
            glib::source::idle_add(|| {
                check_for_new_message();
                return glib::source::Continue(false);
            });
        });
    });
}

// global variable to store  the ui and an input channel
// on the main thread only
thread_local!(
    static GLOBAL: RefCell<Option<(UiModel, mpsc::Receiver<String>)>> = RefCell::new(None);
);

// function to check if a new message has been passed through the
// global receiver and, if so, add it to the UI.
fn check_for_new_message() {
    GLOBAL.with(|global| {
        if let Some((ui, rx)) = &*global.borrow() {
            let received: String = rx.recv().unwrap();
            ui.main_buffer.set_text(&received);
        }
    });
}
Emaciated answered 7/3, 2021 at 18:14 Comment(2)
FYI for anyone else, there is also glib::source::idle_add_once, which may or may not be more suitable for your specific use case.Mackle
Also noteworthy is idle_add is likely to hog your CPU since by definition, it runs whenever the thread is idle. For things on the GUI that need to be updated regularly, it may be more suitable to use glib::source::timeout_add instead to avoid unnecessarily high CPU usage.Mackle

© 2022 - 2024 — McMap. All rights reserved.