Make a GPUI App Interactive - States and Events in GPUI
2 min read
Hi, in the previous article we built a Linear-like issue tracker interface using GPUI Kit. We learned how to create layouts, split the UI into small helper functions and style everything using flexbox, spacing, colours and borders.
It looked like an application but it was still completely static.
In this article, we will continue with that same project and make it interactive.
By the end, users will be able to:
Navigate between sidebar sections.
Filter issues using the All, Assigned to me, and Created by me tabs.
Select an issue card.
Mark an issue as completed or restore it.
See issue counts update automatically.
See an empty state when no issues match the current filters.
While building these features, we will learn how GPUI stores view state, how event handlers access that state, and why cx.notify() is needed after a state change.
From now on, we will use a separate branch for each of the article so that you can follow all the articles and code along. So use part-2 branch for this article
Prerequisites
Before continuing, you should:
Be comfortable with basic Rust structs, enums, vectors, and iterators.
Have completed the first article or downloaded its final project.
Be able to run the existing application with cargo run.
No additional dependencies are required. We will continue using gpui-kit, rust-embed, and anyhow from the first article.
GPUI View State - Starting from the Existing Interface
This is a unit struct and it doesn’t store any data. So, our app has no way to remember which sidebar item we selected, which tab s active or whether an issue is completed
You can also see the same thing in our rendering code:
That final trye means Active issues will always look selected.
Our tabs are also hardcoded in the same way:
.child(tab("All", true)).child(tab("Assigned to me", false)).child(tab("Created by me", false))
To make them interactive, we need to keep these values in our application state and then render the UI using that state.
Before changing anything, run the current project once:
cargo run
Try clicking the navigation items, tabs, issue cards and status circles. Nothing will change, which is exactly what we are going to fix now.
GPUI Kit’s asset example is also a simple stateless view. Its Example struct has no fields and its render() method always returns the same element tree inGPUI.
GPUI View State - Modelling Navigation and Tabs with Enums
We can store the active navigation item as a string but an enum works better here. It makes sure the value can only be one of the sections supported by our app
We are deriving PartialEq and Eq because later we will compare the current value with every navigation item or tab. Clone and Copy will let us move these small enum values into click handlers without losing them from our application state.
One more thing, each navigation item also needs its own title and description, so add these two methods:
impl Navigation { fn title(self) -> &'static str { match self { Self::Inbox => "Inbox", Self::MyIssues => "My issues", Self::ActiveIssues => "Active issues", Self::Projects => "Projects", Self::Views => "Views", } } fn subtitle(self) -> &'static str { match self { Self::Inbox => "Recently updated issues", Self::MyIssues => "Issues currently assigned to you", Self::ActiveIssues => "Issues currently being worked on", Self::Projects => "Issues across every project", Self::Views => "Issues included in your saved view", } }}
Now, lets run the project:
cargo run
The interface should look exactly the same. We have only described the possible values for now, we are not storing or using them yet.
GPUI Kit’s system monitor uses the same idea for its tabs. It represents the available tabs using a MonitorTab enum and later matches that enum while rendering the selected content inGPUI
GPUI Entity State - Turning Hardcoded Issues Into Data
So, right now all our issue is created by directly calling the issue() helper and that’s fine for our static UI but it won’t work once we start filtering issues. For that, we need a collection we cal iterate over.
Now go to the window creation code inside main() and replace:
let view = cx.new(|_| App);
with:
let view = cx.new(|_| App::new());
cx.new(...) stores our App inside a GPUI Entity<App>. You can think of the view variable as a handle to that entity. GPUI keeps this state alive between renders.
GPUI Kit uses the same pattern in its own example. It creates a view using cx.new(...) and passes that view to Root::new(...)insource. There is also a test which creates an entity, updates its value and then reads it using the returned handle insource.
Run the application again:
cargo run
The app should still look the same. We have state now but our helper functions are still rendering the old hardcoded values.
You don’t need to change the body of these functions yet. The new self and cx parameters may remain unused for a short time and that is fine. We are only moving these functions into the object which now owns our state.
You can see that _cx has now become cx. In the first article we used the underscore because we were not using this parameter. We need it now, so we can finally remove that underscore.
Wherever these methods call each other, add self and pass cx. For example:
The interface should render normally. We haven’t added any visible behaviour yet but our rendering helpers can now read the application state and create event listeners for the same App entity.
You can find a similar pattern in GPUI Kit’s settings component. Its rendering methods read the selected state, create children from a collection and use the context to attach listeners inGPUI.
First, we are changing our navigation rust state and after that cx.notify() tells GPUI to render this entity again. GPUI Kit’s tree state uses the same pattern where it changes the selected value and then calls cx.notify()inGPUI
If we don’t call cx.notify(), the value will change in memory but our interface may continue showing the old state.
Now replace the signature and body of sidebar_item() with this version:
The callback used by .on_click() normally receives the click event, window and application context. cx.listener(...) connects that callback to our current entity and gives us &mut App as this. This is how our click handler gets mutable access to the view state. GPUI Kit’s tree rows also combine an element ID with cx.listener(...), update the selected item and notify the view inGPUI
Update all five calls inside sidebar() and pass the matching Navigation value to each item. We can keep the count values fixed for now.
We should also make the header and page heading use our state. Replace the hardcoded "Active" and "Active issues" values with:
.child(self.navigation.title())
And replace the hardcoded subtitle with:
.child(self.navigation.subtitle())
Run the application:
cargo run
Click every sidebar item. The highlight, breadcrumb, page title and description should now change together. This is our first complete state and event flow:
GPUI Dynamic Rendering - Filtering the Issue Collection
Our sidebar changes the current navigation now but every page still shows the same issues. So, before making the tabs clickable, we need a function which calculates the issues we should show.
We are using two filters because the sidebar and the tab both affect our result. An issue must pass both conditions before we display it.
For Projects, we are returning every issue including the completed ones. Later, this will give us a place where we can find and restore a completed issue.
Now add a helper which renders a group from a vector:
Our old group_header() accepted a u32. Change the count parameter to usize so we can pass issues.len() directly:
fn group_header( name: &'static str, count: usize,) -> impl IntoElement { // Keep the existing element body.}
The .children() method takes an iterator and adds every generated element to the parent. In the first article we added five issue cards manually. Now the number of cards can change while the app is running. GPUI Kit’s list component uses the same pattern to give every row an ID, check its selected state, attach a listener and render its children insource.
Rename our old issue() helper to issue_card() and change its parameters to:
The default Active issues page should still show all five issues. If you click My issues, you should only see the three issues where assigned_to_me is true. Click Views and you should see the two issues created by the current user.
Our tabs still don’t work but the sidebar is now filtering actual issue data.
.child(self.tab("All", IssueTab::All, cx)).child(self.tab( "Assigned to me", IssueTab::AssignedToMe, cx,)).child(self.tab( "Created by me", IssueTab::CreatedByMe, cx,))
Run the application:
cargo run
The purple underline should now move to the clicked tab and the issue list should update immediately. GPUI Kit uses the same selected state pattern in its settings pages. The active style comes from the current selection and clicking an item changes that selection before calling cx.notify()inGPUI
Try combining the sidebar and tab filters. For example:
Select My issues.
Select Created by me.
Only ENG-122 should remain because it is assigned to the current user and was also created by the current user. Both filters are being applied to the same issue collection.
Click a few different issue cards. The selected card should have a purple border and a slightly lighter background. If you change the navigation section or tab, the selection will be cleared because both handlers set selected_issue back to None.
GPUI Kit’s tree component also keeps selection as an optional index. While rendering, it compares every row index with that selected value and uses a listener to change the selection when a row is clicked inGPUI
Our status circle is inside a clickable issue card. A click can move from the child to its parent, so clicking the status circle would also select the complete card if we didn’t stop it.
This line stops the click after our status handler has processed it:
cx.stop_propagation();
GPUI Kit does the same thing for nested attachment actions. It stops the event so an inner control does not also activate the clickable element around it inGPUI.
We should also make a completed issue look different. Add this to the element which displays issue.title:
Our sidebar counts and footer are still hardcoded. If we keep them like this, they will become incorrect as soon as an issue changes. We should calculate them from the same issue state instead.
Complete an issue and look at the sidebar and footer counts. They should update automatically. You can also switch between tabs and check that the footer always matches the number of visible cards.
This is why rendering from state is useful. We don’t need to manually update three different labels. We change the issue once, call cx.notify() and every count is calculated again from the new state.
GPUI Kit’s list component also derives collection information while rendering. It reads the total number of items from its row cache and uses that value for every rendered list item inGPUI
GPUI Conditional Rendering - Displaying an Empty State
Some sidebar and tab combinations may return no issues. If we render a blank area, it may look like something is broken, so let’s add a small empty state.
Run the application and choose a combination which has no matching issues. You should now see our empty state instead of a blank page.
GPUI Kit’s dock follows the same conditional rendering idea. It renders the active panel when one exists and asks the renderer for an empty element when there is no active panel inGPUI
GPUI Component State - Disabling Controls Reserved for Later
The header still has Filter, Sort, New issue and overflow buttons from the first article. We are not implementing those features in this article, so these buttons should not look like they are working.
Add Disableable to the component imports at the top of the file. GPUI Kit defines this trait for components which can have a disabled state in, and Button implements this trait inGPUI
use gpui_kit::component::{ Disableable, Icon, IconName, Root, Sizable, button::{Button, ButtonVariants},};
Now add .disabled(true) to the buttons we are not using. For example:
Once the tests pass, launch the application one final time:
cargo run
Now check the complete interaction flow:
Click every sidebar item and watch the heading change.
Switch between all three tabs.
Select several issue cards.
Complete an issue from Active issues.
Open Projects and restore it.
Confirm that all visible counts update.
Produce a filter with no results and check the empty state.
GPUI Kit tests component state without launching a normal application window as well. Its button tests construct buttons, resolve their selected and disabled styles and assert the result directly insource.
I hope you learned something new about GPUI. I know we are going slow but I would like to keep it this way so that we can learn it slowly and steadily.
In the next one we will learn how to break our app into smaller pieces and make it modular. Till then, have a great life