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.

This article starts from the final code in Your First GPUI App — Building a Desktop UI in Rust. You can also get the starting project from the GitHub repository.

Source code: click here

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

Let’s open our project from the Building Your First Desktop UI with GPUI and you can see that our application view looks like this:

struct App;

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:

.child(sidebar_item(
    IconName::CircleX,
    "Active issues",
    None,
    true,
))

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 in GPUI.

Link to original

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

Lets add these enums above struct App:

#[derive(Clone, Copy, PartialEq, Eq)]
enum Navigation {
    Inbox,
    MyIssues,
    ActiveIssues,
    Projects,
    Views,
}
 
#[derive(Clone, Copy, PartialEq, Eq)]
enum IssueTab {
    All,
    AssignedToMe,
    CreatedByMe,
}

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 in GPUI

Link to original

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.

Lets add this structure below the enum:

#[derive(Clone, Copy)]
struct Issue {
    id: &'static str,
    title: &'static str,
    group: &'static str,
    team: &'static str,
    assignee: &'static str,
    updated: &'static str,
    priority: &'static str,
    priority_color: u32,
    assigned_to_me: bool,
    created_by_me: bool,
    completed: bool,
}

Most of these fields are values we were already showing inside an issue card. We have also added four fields that we will need for interactions:

  • group tells us where the issue should be rendered.
  • assigned_to_me supports the assigned tab and My issues section.
  • created_by_me supports the created tab and Views section.
  • completed lets the user change the status of an issue.

Now, replace our unit App struct with this:

struct App {
    navigation: Navigation,
    active_tab: IssueTab,
    selected_issue: Option<&'static str>,
    issues: Vec<Issue>,
}

Then add a new() method where we can set the default state and add our issues:

impl App {
    fn new() -> Self {
        Self {
            navigation: Navigation::ActiveIssues,
            active_tab: IssueTab::All,
            selected_issue: None,
            issues: vec![
                Issue {
                    id: "ENG-124",
                    title: "Fix authentication redirect",
                    group: "ENGINEERING",
                    team: "Backend",
                    assignee: "John",
                    updated: "12m",
                    priority: "HIGH",
                    priority_color: 0xd95c5c,
                    assigned_to_me: true,
                    created_by_me: false,
                    completed: false,
                },
                Issue {
                    id: "ENG-123",
                    title: "Improve settings page",
                    group: "ENGINEERING",
                    team: "Frontend",
                    assignee: "Sarah",
                    updated: "1h",
                    priority: "MEDIUM",
                    priority_color: 0xd9a441,
                    assigned_to_me: false,
                    created_by_me: true,
                    completed: false,
                },
                Issue {
                    id: "ENG-122",
                    title: "Update API documentation",
                    group: "ENGINEERING",
                    team: "Documentation",
                    assignee: "Alex",
                    updated: "3h",
                    priority: "LOW",
                    priority_color: 0x5b8fd8,
                    assigned_to_me: true,
                    created_by_me: true,
                    completed: false,
                },
                Issue {
                    id: "PRO-087",
                    title: "Redesign onboarding flow",
                    group: "PRODUCT",
                    team: "Product",
                    assignee: "Maya",
                    updated: "5h",
                    priority: "MEDIUM",
                    priority_color: 0xd9a441,
                    assigned_to_me: false,
                    created_by_me: false,
                    completed: false,
                },
                Issue {
                    id: "PRO-086",
                    title: "Add keyboard shortcuts",
                    group: "PRODUCT",
                    team: "Desktop",
                    assignee: "David",
                    updated: "1d",
                    priority: "LOW",
                    priority_color: 0x5b8fd8,
                    assigned_to_me: true,
                    created_by_me: false,
                    completed: false,
                },
            ],
        }
    }
}

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(...) in source. There is also a test which creates an entity, updates its value and then reads it using the returned handle in source.

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.

Link to original

GPUI Render Methods - Giving Rendering Helpers Access to State

Our sidebar() and main_content() helpers are just functions right now, which means they cannot read self.navigation, self.active_tab or self.issues.

Move the following functions into the existing impl App block and turn them into methods:

fn sidebar(&self, cx: &Context<Self>) -> impl IntoElement
fn sidebar_item(
    &self,
    icon: IconName,
    label: &'static str,
    count: Option<&'static str>,
    active: bool,
) -> impl IntoElement
fn main_content(&self, cx: &Context<Self>) -> impl IntoElement
fn tab(
    &self,
    label: &'static str,
    active: bool,
) -> impl IntoElement

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.

Update the root render method:

impl Render for App {
    fn render(
        &mut self,
        _window: &mut Window,
        cx: &mut Context<Self>,
    ) -> impl IntoElement {
        div()
            .size_full()
            .bg(rgb(0x0f1012))
            .text_color(rgb(0xe8e9ea))
            .child(
                div()
                    .size_full()
                    .flex()
                    .flex_row()
                    .child(self.sidebar(cx))
                    .child(self.main_content(cx)),
            )
    }
}

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:

.child(self.sidebar_item(
    IconName::Inbox,
    "Inbox",
    Some("3"),
    false,
))

and:

.child(self.tab("All", true))

Run the application now:

cargo run

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 in GPUI.

Link to original

GPUI Event Listeners - Handling Sidebar Clicks

Now we can make our first part interactive. Add this method inside impl App:

fn set_navigation(
    &mut self,
    navigation: Navigation,
    cx: &mut Context<Self>,
) {
    self.navigation = navigation;
    self.selected_issue = None;
    cx.notify();
}

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() in GPUI

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:

fn sidebar_item(
    &self,
    icon: IconName,
    label: &'static str,
    count: Option<usize>,
    navigation: Navigation,
    cx: &Context<Self>,
) -> impl IntoElement {
let active = self.navigation == navigation;
 
div()
    .id(format!("sidebar-item-{label}"))
    .h(px(31.0))
    .flex()
    .items_center()
    .px(px(8.0))
    .rounded(px(6.0))
    .cursor_pointer()
    .when(active, |this| this.bg(rgb(0x202227)))
    .hover(|this| this.bg(rgb(0x1d2024)))
    .on_click(cx.listener(move |this, _, _, cx| {
        this.set_navigation(navigation, cx);
    }))
    .child(
        Icon::new(icon)
            .size(px(14.0))
            .text_color(if active {
                rgb(0xd9dbe0)
            } else {
                rgb(0x777b83)
            }),
    )
    .child(
        div()
            .ml(px(9.0))
            .flex_1()
            .text_size(px(12.0))
            .text_color(if active {
                rgb(0xe0e1e4)
            } else {
                rgb(0x858990)
            })
            .child(label),
    )
    .when_some(count, |this, count| {
        this.child(
            div()
                .text_size(px(10.0))
                .text_color(rgb(0x656970))
                .child(count.to_string()),
        )
    })
}

An interactive GPUI element needs an ID, so we are creating one using its label:

.id(format!("sidebar-item-{label}"))

Now look at the listener:

.on_click(cx.listener(move |this, _, _, cx| {
    this.set_navigation(navigation, cx);
}))

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 in GPUI

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:

click -> set_navigation() -> cx.notify() -> render()

Link to original

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.

Add this method to impl App:

fn visible_issues(&self) -> Vec<Issue> {
    self.issues
        .iter()
        .copied()
        .filter(|issue| match self.navigation {
            Navigation::Inbox | Navigation::ActiveIssues => {
                !issue.completed
            }
            Navigation::MyIssues => issue.assigned_to_me,
            Navigation::Projects => true,
            Navigation::Views => issue.created_by_me,
        })
        .filter(|issue| match self.active_tab {
            IssueTab::All => true,
            IssueTab::AssignedToMe => issue.assigned_to_me,
            IssueTab::CreatedByMe => issue.created_by_me,
        })
        .collect()
}

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:

fn issue_group(
    &self,
    name: &'static str,
    issues: Vec<Issue>,
    cx: &Context<Self>,
) -> impl IntoElement {
    div()
        .flex()
        .flex_col()
        .gap(px(1.0))
        .child(group_header(name, issues.len()))
        .children(
            issues
                .into_iter()
                .map(|issue| self.issue_card(issue, cx)),
        )
}

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 in source.

Rename our old issue() helper to issue_card() and change its parameters to:

fn issue_card(
    &self,
    issue: Issue,
    cx: &Context<Self>,
) -> impl IntoElement

Inside the existing styling code, replace the individual parameters with the matching fields from issue:

issue.id
issue.title
issue.team
issue.assignee
issue.updated
issue.priority
issue.priority_color

At the beginning of main_content(), get the visible issues and split them into groups:

let visible = self.visible_issues();
 
let engineering = visible
    .iter()
    .copied()
    .filter(|issue| issue.group == "ENGINEERING")
    .collect::<Vec<_>>();
 
let product = visible
    .iter()
    .copied()
    .filter(|issue| issue.group == "PRODUCT")
    .collect::<Vec<_>>();

Now remove the hardcoded Engineering and Product issue blocks and replace them with:

.when(!engineering.is_empty(), |this| {
    this.child(self.issue_group(
        "ENGINEERING",
        engineering,
        cx,
    ))
})
.when(!product.is_empty(), |this| {
    this.child(
        div()
            .mt(px(12.0))
            .child(self.issue_group("PRODUCT", product, cx)),
    )
})

Run the project:

cargo run

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.

Link to original

GPUI Click Events - Making the Tabs Interactive

We can now use the same pattern for our tabs. Add another method for changing the active tab:

fn set_tab(&mut self, tab: IssueTab, cx: &mut Context<Self>) {
    self.active_tab = tab;
    self.selected_issue = None;
    cx.notify();
}

Then replace the old tab() function with:

fn tab(
    &self,
    label: &'static str,
    tab: IssueTab,
    cx: &Context<Self>,
) -> impl IntoElement {
let active = self.active_tab == tab;
 
div()
    .id(format!("issue-tab-{label}"))
    .h_full()
    .flex()
    .items_center()
    .cursor_pointer()
    .text_size(px(12.0))
    .text_color(if active {
        rgb(0xe2e3e6)
    } else {
        rgb(0x696d75)
    })
    .when(active, |this| {
        this.border_b_2().border_color(rgb(0x7c6ff2))
    })
    .on_click(cx.listener(move |this, _, _, cx| {
        this.set_tab(tab, cx);
    }))
    .child(label)
}

Update the three tab calls inside main_content():

.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() in GPUI

Try combining the sidebar and tab filters. For example:

  1. Select My issues.
  2. 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.

Link to original

GPUI Selection State - Selecting an Issue

GPUI Selection State - Selecting an Issue

Now, let’s make our issue cards selectable. Add this handler:

fn select_issue(
    &mut self,
    id: &'static str,
    cx: &mut Context<Self>,
) {
    self.selected_issue = Some(id);
    cx.notify();
}

At the beginning of issue_card(), check whether this card is selected:

let selected = self.selected_issue == Some(issue.id);
let card_id = issue.id;

Add an ID, pointer cursor and click listener to the outer card element:

.id(format!("issue-card-{}", issue.id))
.cursor_pointer()
.on_click(cx.listener(move |this, _, _, cx| {
    this.select_issue(card_id, cx);
}))

Now change the fixed border and background colours so they depend on selected:

.border_color(if selected {
    rgb(0x7c6ff2)
} else {
    rgb(0x202227)
})
.bg(if selected {
    rgb(0x1c1d28)
} else {
    rgb(0x141619)
})

Run the application:

cargo run

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 in GPUI

Link to original

GPUI Mutable State - Completing and Restoring Issues

Selecting an issue only changes selected_issue. To complete an issue, we need to change one of the items inside Vec<Issue>.

Add this method:

fn toggle_issue(
    &mut self,
    id: &'static str,
    cx: &mut Context<Self>,
) {
    if let Some(issue) = self
        .issues
        .iter_mut()
        .find(|issue| issue.id == id)
    {
        issue.completed = !issue.completed;
    }
 
    cx.notify();
}

We are using iter_mut() because reading the matching issue is not enough here. We need a mutable reference so that we can change completed.

At the beginning of issue_card(), save the issue ID for the status handler:

let status_id = issue.id;

Now replace the old empty status circle with this:

div()
    .id(format!("issue-status-{}", issue.id))
    .size(px(18.0))
    .rounded_full()
    .border_2()
    .border_color(if issue.completed {
        rgb(0x4e9f7a)
    } else {
        rgb(issue.priority_color)
    })
    .when(issue.completed, |this| {
        this.bg(rgb(0x4e9f7a))
    })
    .mr(px(11.0))
    .flex()
    .items_center()
    .justify_center()
    .text_size(px(11.0))
    .text_color(rgb(0x101113))
    .cursor_pointer()
    .on_click(cx.listener(move |this, _, _, cx| {
        cx.stop_propagation();
        this.toggle_issue(status_id, cx);
    }))
    .when(issue.completed, |this| this.child("✓"))

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 in GPUI.

We should also make a completed issue look different. Add this to the element which displays issue.title:

.when(issue.completed, |this| {
    this.line_through().text_color(rgb(0x656970))
})

Run the application:

cargo run

Click a status circle on the Active issues page. That issue should disappear because this page only shows incomplete issues.

Next:

  1. Open Projects from the sidebar.
  2. Find the completed issue.
  3. Click its green status circle.

The checkmark and strikethrough should disappear. Go back to Active issues and you should see the restored issue again.

Link to original

GPUI Derived State - Calculating Issue Counts

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.

Add these methods:

fn active_count(&self) -> usize {
    self.issues
        .iter()
        .filter(|issue| !issue.completed)
        .count()
}
 
fn assigned_count(&self) -> usize {
    self.issues
        .iter()
        .filter(|issue| {
            issue.assigned_to_me && !issue.completed
        })
        .count()
}

Pass them to the matching sidebar items:

.child(self.sidebar_item(
    IconName::Inbox,
    "Inbox",
    Some(self.active_count()),
    Navigation::Inbox,
    cx,
))
.child(self.sidebar_item(
    IconName::User,
    "My issues",
    Some(self.assigned_count()),
    Navigation::MyIssues,
    cx,
))

At the beginning of main_content(), save the number of visible issues before we move the vectors into their group elements:

let visible_count = visible.len();

Replace the hardcoded footer with:

.child(
    div()
        .pt(px(12.0))
        .text_size(px(11.0))
        .text_color(rgb(0x555960))
        .child(format!(
            "Showing {visible_count} of {} issues",
            self.issues.len(),
        )),
)

Run the project:

cargo run

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 in GPUI

Link to original

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.

Add this free helper function outside impl App:

fn empty_state() -> impl IntoElement {
    div()
        .h(px(170.0))
        .w_full()
        .flex()
        .flex_col()
        .items_center()
        .justify_center()
        .rounded(px(8.0))
        .border_1()
        .border_color(rgb(0x272a2f))
        .bg(rgb(0x131416))
        .child(
            div()
                .text_size(px(14.0))
                .font_weight(FontWeight::MEDIUM)
                .child("No issues found"),
        )
        .child(
            div()
                .mt(px(6.0))
                .text_size(px(11.0))
                .text_color(rgb(0x696d75))
                .child("Try another tab or navigation item."),
        )
}

After the issue groups inside main_content(), add it conditionally:

.when(visible.is_empty(), |this| {
    this.child(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 in GPUI

Link to original

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 in GPUI

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:

Button::new("filter")
    .ghost()
    .label("Filter")
    .small()
    .disabled(true)

Do the same for sort, new and more. We can keep the same design from the first article without making these buttons look usable.

Run the application:

cargo run

These four buttons should now look disabled while the sidebar items, tabs, issue cards and status circles will remain interactive.

Link to original

GPUI State Login - Testing Filters Without Opening a Window

Most of our application is visual but the filtering logic is just normal Rust. We can test it without opening a GPUI window.

Add these tests at the bottom of main.rs:

#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn default_view_shows_every_active_issue() {
        let app = App::new();
        assert_eq!(app.visible_issues().len(), 5);
    }
 
    #[test]
    fn assigned_tab_filters_the_issue_collection() {
        let mut app = App::new();
        app.active_tab = IssueTab::AssignedToMe;
        assert_eq!(app.visible_issues().len(), 3);
    }
 
    #[test]
    fn completed_issues_leave_the_active_view_but_remain_in_projects() {
        let mut app = App::new();
        app.issues[0].completed = true;
 
        assert_eq!(app.visible_issues().len(), 4);
 
        app.navigation = Navigation::Projects;
        assert_eq!(app.visible_issues().len(), 5);
    }
 
    #[test]
    fn navigation_and_tabs_are_combined() {
        let mut app = App::new();
        app.navigation = Navigation::MyIssues;
        app.active_tab = IssueTab::CreatedByMe;
 
        let issues = app.visible_issues();
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].id, "ENG-122");
    }
}

Run them with:

cargo test

You should see:

test result: ok. 4 passed; 0 failed

Once the tests pass, launch the application one final time:

cargo run

Now check the complete interaction flow:

  1. Click every sidebar item and watch the heading change.
  2. Switch between all three tabs.
  3. Select several issue cards.
  4. Complete an issue from Active issues.
  5. Open Projects and restore it.
  6. Confirm that all visible counts update.
  7. 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 in source.

Link to original

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