Building a hacker news app using gtk-rs

We'll use the gtk crate to build a simple hacker news app that fetches the top 10 trending stories from the https://news.ycombinator.com/ website. Hacker News is a website focusing on digital technologies and tech news from around the world. To start with, we have created a basic wireframe model of our app:

At the very top, we have the app header bar, which has a Refresh button on the left that can update our stories on demand. A story is a news entry that's posted by users on the Hacker News website. The header bar also contains the app title at the center and the usual window controls on the right. Below that, we have our main scrollable window where our stories will be rendered vertically as a story widget. The story widget is made up of two widgets: a widget for displaying the story's name and its score, and another for rendering a link to the story that can be clicked on in the user's default browser. Pretty simple!

Note: As we are using the gtk crate, which binds to native C libraries, we need to install the developmental C libraries for the gtk framework. For Ubuntu and Debian platforms, we can install these dependencies by running the following:
sudo apt-get install libgtk-3-dev

Please refer to the gtk-rs documentation page at http://gtk-rs.org/docs/requirements.html for information on setting gtk up on other platforms.

To start things off, we'll create a new cargo project by running cargo new hews. We have creatively named our app Hews, which is short for H in hacker + ews from news.

The following are the dependencies that we'll need in our Cargo.toml file:

# hews/Cargo.toml

[dependencies]
gtk = { version = "0.3.0", features = ["v3_18"] }
reqwest = "0.9.5"
serde_json = "1.0.33"
serde_derive = "1.0.82"
serde = "1.0.82"

We are using a bunch of crates here:

  • gtk: This is used to build the GUI of the app. We use the bindings for gtk version 3.18 here.
  • reqwest: This is used for fetching stories from the Hacker News API. reqwest is a high-level wrapper over the hyper crate. We are using the reqwest synchronous API for simplicity.
  • serde_json: This is used for seamlessly converting the fetched JSON response from the network to a strongly typed Story struct.
  • serde, serde_derive: These provide traits and implementations for automatically deriving serialization code for built-in Rust types. By using the Serialize and Deserialize traits from serde_derive, we can serialize and deserialize any native Rust type into a given format. serde_json relies on the same functionality to convert a serde_json::Value type into a Rust type.

To show news articles in our app, we'll fetch them by making HTTP requests to the official hacker news API, which is documented at https://github.com/HackerNews/API. We have divided our app into two modules. First, we have the app module, which contains all the UI-related functionality for rendering the app on-screen and handling UI state updates from the user. Second, we have the hackernews module, which provides APIs for fetching stories from the network. It runs in a separate thread so not block the GUI thread when network requests happen, which is a blocking I/O operation. From the hacker news API, a story is an item containing a news title and a link to the news, along with other properties such as how popular the story is and a list of comments on the story.

To make this example simpler and easier to follow, our app does not have proper error handling and includes many unwrap() calls, which is a bad practice from an error handling perspective. After you are done exploring the demo, you are encouraged to integrate a better error handling strategy in the app. With that said, let's go through the code step by step.

First, we'll look at the entry point of our app in main.rs:

// hews/src/main.rs

mod app;
mod hackernews;
use app::App;

fn main() {
let (app, rx) = App::new();
app.launch(rx);
}

In our main function, we call App::new(), which returns an App instance, along with rx, which is a mpsc::Receiver. To keep our GUI decoupled from network requests, all state updates in hews are handled asynchronously via channels. The App instance internally invokes mpsc::channel(), giving back tx and rx. It stores the tx with it and also passes it to the network thread, allowing it to notify the UI of any new story. Following the new method call, we invoke launch on app, passing in the rx, which is used to listen for events from the network thread in the GUI thread.

Next, let's go through our app module in the app.rs module, which handles most of the orchestration needed to render our app on-screen.

If you want to find out more about the widget explanation that follows, look for gtk-rs's excellent documentation at https://gtk-rs.org/docs/gtk/, where you can search for any widget and explore more about its properties.

First, we have our App struct, which is the entry point for all things GUI:

// hews/src/app.rs

pub struct App {
window: Window,
header: Header,
stories: gtk::Box,
spinner: Spinner,
tx: Sender<Msg>,
}

This struct contains a bunch of fields:

  • window: This contains the base gtk::Window widget. Every gtk application starts with a window to which we can add child widgets in different layouts to design our GUI.
  • header: This is a struct that's defined by us and wraps a gtk::HeaderBar widget, which acts as the title bar for our app window.
  • stories: This is a container gtk::Box widget that will store our stories vertically.
  • spinner: This is a gtk::Spinner widget that provides a visual cue for loading stories.
  • tx: This is an mpsc Sender to send events from the GUI to the network thread. The messages are of type Msg, which is an enum:
pub enum Msg {
NewStory(Story),
Loading,
Loaded,
Refresh,
}

Our app starts with the initial state as Loading when the fetch_posts method is called from the hackernews module. We'll see that later. NewStory is the state that occurs when a new story is fetched. Loaded is the state that occurs when all of the stories are loaded and Refresh is sent when the user wants to reload the stories.

Let's move on to the methods on the App struct. Here's our new method:

impl App {
pub fn new() -> (App, Receiver<Msg>) {
if gtk::init().is_err() {
println!("Failed to init hews window");
process::exit(1);
}

The new method first starts the gtk event loop using gtk::init(). If that fails, we exit, printing a message to the console:

        let (tx, rx) = channel();
let window = gtk::Window::new(gtk::WindowType::Toplevel);
let sw = ScrolledWindow::new(None, None);
let stories = gtk::Box::new(gtk::Orientation::Vertical, 20);
let spinner = gtk::Spinner::new();
let header = Header::new(stories.clone(), tx.clone());

Then, we create our tx and rx channel endpoints for communicating between the network thread and the GUI thread. Next, we create our window, which is a TopLevel window. Now, multiple stories might not fit into our app window if the window is resized, so we need a scrollable window here. For that, we will create a ScrolledWindow instance as sw. However, a gtk ScrolledWindow accepts only a single child within it and we need to store multiple stories, which is a problem. Fortunately, we can use the gtk::Box type, which is a generic container widget that's used to lay out and organize child widgets. Here, we create a gtk::Box instance as stories with the Orientation::Vertical orientation so that each of our stories renders vertically on top of each other. We also want to show a spinner at the top of our scroll widget when the stories are being loaded, so we will create a gtk::Spinner widget and add it to stories to render it at the very top. We will also create our Header bar and pass a reference to stories as well as tx. Our header contains the refresh button and has a click handler, which needs the stories container to clear items within it, allowing us to load new stories:

        stories.pack_start(&spinner, false, false, 2);
sw.add(&stories);
window.add(&sw);
window.set_default_size(600, 350);
window.set_titlebar(&header.header);

Next, we start composing our widgets. First, we add the spinner to stories. Then, we add the stories container widget to our scroll widget, sw, which is then added to our parent window. We also set the window size with set_default_size. We then set its title bar with set_titlebar, passing in our header. Following that, we attach a signal handler to our window:

        window.connect_delete_event(move |_, _| {
main_quit();
Inhibit(false)
});

This will quit the app if we call main_quit(). The Inhibit(false) return type does not stop the signal from propagating to the default handler for delete_event. All widgets have a default signal handler. The signal handlers on the widgets in the gtk crate follow the naming convention of connect_<event> and take in a closure with the widget as their first parameter and the event object.

Next, let's look at the launch method on App, which is called in main.rs:

    pub fn launch(&self, rx: Receiver<Msg>) {
self.window.show_all();
let client = Arc::new(reqwest::Client::new());
self.fetch_posts(client.clone());
self.run_event_loop(rx, client);
}

First, we enable the window widget, along with its child widgets. We make them visible by calling the show_all method as widgets in gtk are invisible by default. Next, we create our HTTP Client and wrap it in an Arc as we want to share it with our network thread. We then call fetch_posts, passing our client. Following that, we run our event loop by calling run_event_loop, passing in rx. The fetch_posts method is defined like so:

    fn fetch_posts(&self, client: Arc<Client>) {
self.spinner.start();
self.tx.send(Msg::Loading).unwrap();
let tx_clone = self.tx.clone();
top_stories(client, 10, &tx_clone);
}

It starts our spinner animation by calling its start method, and sends the Loading message as the initial state. It then calls the top_stories function from the hackernews module, passing 10 as the number of stories to fetch and a Sender to notify the GUI thread of new stories.

After calling fetch_posts, we call the run_event_loop method on App, which is defined like so:

    fn run_event_loop(&self, rx: Receiver<Msg>, client: Arc<Client>) {
let container = self.stories.clone();
let spinner = self.spinner.clone();
let header = self.header.clone();
let tx_clone = self.tx.clone();

gtk::timeout_add(100, move || {
match rx.try_recv() {
Ok(Msg::NewStory(s)) => App::render_story(s, &container),
Ok(Msg::Loading) => header.disable_refresh(),
Ok(Msg::Loaded) => {
spinner.stop();
header.enable_refresh();
}
Ok(Msg::Refresh) => {
spinner.start();
spinner.show();
(&tx_clone).send(Msg::Loading).unwrap();
top_stories(client.clone(), 10, &tx_clone);
}
Err(_) => {}
}
gtk::Continue(true)
});

gtk::main();
}

First, we get references to a bunch of objects that we'll use. Following that, we call gtk::timeout_add, which runs the given closure every 100 milliseconds. Within the closure, we poll on rx in a non-blocking way using try_recv() for events from the network or GUI thread. When we get a NewStory message, we call render_story. When we receive a Loading message, we disable the refresh button. In the case of the Loaded message, we stop our spinner and enable the refresh button so that the user can reload stories again. Finally, in the case of receiving a Refresh message, we start the spinner again and send the Loading message to the GUI thread itself, followed by calling the top_stories method.

Our render_story method is defined as follows:

    fn render_story(s: Story, stories: &gtk::Box) {
let title_with_score = format!("{} ({})", s.title, s.score);
let label = gtk::Label::new(&*title_with_score);
let story_url = s.url.unwrap_or("N/A".to_string());
let link_label = gtk::Label::new(&*story_url);
let label_markup = format!("<a href="{}">{}</a>", story_url, story_url);
link_label.set_markup(&label_markup);
stories.pack_start(&label, false, false, 2);
stories.pack_start(&link_label, false, false, 2);
stories.show_all();
}

The render_story method gets the Story instance as s and the stories container widget as arguments before creating two labels: title_with_score, which holds the story title along with its score, and link_label, which holds the link to the story. For the link_label, we will add a custom markup that contains an <a> tag with the URL. Finally, we put both these labels onto our stories container and call show_all at the end to make those labels visible on the screen.

Our Header struct and its methods, which we mentioned previously, are part of the App struct and are as follows:

// hews/src/app.rs

#[derive(Clone)]
pub struct Header {
pub header: HeaderBar,
pub refresh_btn: Button
}

impl Header {
pub fn new(story_container: gtk::Box, tx: Sender<Msg>) -> Header {
let header = HeaderBar::new();
let refresh_btn = gtk::Button::new_with_label("Refresh");
refresh_btn.set_sensitive(false);
header.pack_start(&refresh_btn);
header.set_title("Hews - popular stories from hacker news");
header.set_show_close_button(true);

refresh_btn.connect_clicked(move |_| {
for i in story_container.get_children().iter().skip(1) {
story_container.remove(i);
}
tx.send(Msg::Refresh).unwrap();
});

Header {
header,
refresh_btn
}
}

fn disable_refresh(&self) {
self.refresh_btn.set_label("Loading");
self.refresh_btn.set_sensitive(false);
}

fn enable_refresh(&self) {
self.refresh_btn.set_label("Refresh");
self.refresh_btn.set_sensitive(true);
}
}

This struct contains the following fields:

  • header: A gtk HeaderBar, which is like a horizontal gtk Box that's suitable for title bars for a window
  • refresh_btn: A gtk Button that is used to reload stories on demand

Header also has three methods:

  • new: This creates a new Header instance. Within the new method, we create a new gtk HeaderBar, set its close button to show, and add a title. Then, we create a Refresh button and attach a click handler to it using the connect_clicked method, which takes in a closure. Within this closure, we iterate over all of the children of the scrolled window container, which are passed to this method as story_container. However, we skip the first one because the first widget is a Spinner and we want to keep it across multiple reloads to show its progress.
  • disable_refresh: This disables the refresh button, setting its sensitivity to false.
  • enable_refresh: This enables the refresh button, setting its sensitivity to true.

Next, let's go through our hackernews module, which does all the heavy lifting of getting the stories as json from the API endpoint and parsing it as a Story instance using serde_json. Here's the first piece of content of hackernews.rs:

// hews/src/hackernews.rs

use crate::app::Msg;
use serde_json::Value;
use std::sync::mpsc::Sender;
use std::thread;
use serde_derive::Deserialize;

const HN_BASE_URL: &str = "https://hacker-news.firebaseio.com/v0/";

#[derive(Deserialize, Debug)]
pub struct Story {
pub by: String,
pub id: u32,
pub score: u64,
pub time: u64,
pub title: String,
#[serde(rename = "type")]
pub _type: String,
pub url: Option<String>,
pub kids: Option<Value>,
pub descendents: Option<u64>,
}

First, we have a declaration of the base URL endpoint, HN_BASE_URL, for the hackernews API that's hosted on firebase. Firebase is a real-time database from Google. Then, we have the Story struct declaration, annotated with the Deserialize and Debug traits. The Deserialize trait comes from the serde_derive crate, which provides a derive macro to convert any value into a native Rust type. We need it because want to be able to parse the incoming json reply from the network as a Story struct.

The Story struct contains the same fields as the ones that are found in the json reply from the stories endpoint. For more information on the json structure, refer to https://github.com/HackerNews/API#items. Also, among all our fields in the Story struct, we have one field named type. However, type is also a keyword in Rust for declaring type aliases and it's invalid for type to be a field of a struct, so we will name it _type instead. However, this wouldn't parse as our json reply as a field named type. To solve this conflict, serde provides us with a field-level attribute to allow us to parse values, even in the case of such conflicts when using the #[serde(rename = "type")] attribute on the field. The value of rename should match whatever the value is in the incoming json response's field name. Next, let's look at the set of methods that are provided by this module:

// hews/src/hackernews.rs

fn fetch_stories_parsed(client: &Client) -> Result<Value, reqwest::Error> {
let stories_url = format!("{}topstories.json", HN_BASE_URL);
let body = client.get(&stories_url).send()?.text()?;
let story_ids: Value = serde_json::from_str(&body).unwrap();
Ok(story_ids)
}

pub fn top_stories(client: Arc<Client>, count: usize, tx: &Sender<Msg>) {
let tx_clone = tx.clone();
thread::spawn(move || {
let story_ids = fetch_stories_parsed(&client).unwrap();
let filtered: Vec<&Value> = story_ids.as_array()
.unwrap()
.iter()
.take(count)
.collect();

let loaded = !filtered.is_empty();

for id in filtered {
let id = id.as_u64().unwrap();
let story_url = format!("{}item/{}.json", HN_BASE_URL, id);
let story = client.get(&story_url)
.send()
.unwrap()
.text()
.unwrap();
let story: Story = serde_json::from_str(&story).unwrap();
tx_clone.send(Msg::NewStory(story)).unwrap();
}

if loaded {
tx_clone.send(Msg::Loaded).unwrap();
}
});
}

Our only public function that's been exposed by this module is top_stories. This function takes a reference to Client, which comes from the reqwest crate, then a count parameter specifying how many stories to retrieve, and a Sender instance tx, which can send messages of type Msg, an enum. tx is used to communicate to the GUI thread about the state of our network request. Initially, the GUI starts in the Msg::Loading state, which keeps the refresh button disabled.

Within this function, we first clone our copy of the tx sender and then spawn a thread where we'll use this tx. We spawn a thread so not to block the UI thread when the network request is in progress. Within the closure, we call fetch_stories_parsed(). In this method, we first construct our /top_stories.json endpoint by concatenating it with HN_BASE_URL using the format! macro. We then make a request to the constructed endpoint to get a list of all stories. We call the text() method to convert the response into a json string. The returned json response is a list of story IDs, each of which can be used to make another set of requests, which gives us detailed information on the story as another json object. We then parse this response using serde_json::from_str(&body). This gives us a Value enum value, which is a parsed json array containing a list of story IDs.

So, once we have the story IDs stored in story_ids, we explicitly convert it into an array by calling as_array() and then we iter() on it and limit the stories we want by calling take(count), followed by calling collect() on it, which gives us back a Vec<Story>:

        let story_ids = fetch_stories_parsed(&client).unwrap();
let filtered: Vec<&Value> = story_ids.as_array()
.unwrap()
.iter()
.take(count)
.collect();

Next, we check whether our filtered story ID is empty. If it is, we set the loaded variable to false:

       let loaded = !filtered.is_empty();

The loaded boolean value is used to send a notification to the main GUI thread if any of our story was loaded. Next, if the filtered list is not empty, we iterate over our filtered stories and construct a story_url :

        for id in filtered {
let id = id.as_u64().unwrap();
let story_url = format!("{}item/{}.json", HN_BASE_URL, id);
let story = client.get(&story_url)
.send()
.unwrap()
.text()
.unwrap();
let story: Story = serde_json::from_str(&story).unwrap();
tx_clone.send(Msg::NewStory(story)).unwrap();
}

We make get requests for each constructed story_url  from the story id, take the json response, and parse it as a Story struct using the serde_json::from_str function. Following that, we send the story by wrapping it in Msg::NewStory(story) to the GUI thread using tx_clone.

Once we have sent all the stories, we send a Msg::Loaded message to the GUI thread, which enables the refresh button so that the user can reload the stories again.

All right! It's time for us to read popular news stories on our app. After running cargo run, we can see our stories being pulled and rendered in the window:

Upon clicking the link of any for the stories, hews will open in your default browser. That's about it. We've made our GUI application in Rust using very few lines of code. Now, it's time for you to explore and experiment with the app.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset