Dec 21, 2025
7 min read
GUI,
Rust,

How to Implement a Circular Progress Bar in Rust GPUI Framework

The top-level API of GPUI cannot implement a feature-rich circular progress bar. Of course, a simple circular loading ring can be implemented very easily, such as using SVG elements and then adding SVG rotation animations.

That thing:

demo1

But when adding a progress value to this circular loading ring, the above solution is somewhat inadequate (unless you want to prepare 100 SVG elements and then set the display of SVG elements based on the progress value).

The best approach is to use the canvas API. Moreover, we cannot implement this component from Render or RenderOnce, but instead implement it from the lower-level Element.

The relationship between them is roughly like this:

graph TD
    A[Element] --> B[RenderOnce]
    B --> C[Render]
    
    A1[Basic element features] --> A
    B1[Single render] --> B
    C1[Mutable state rendering] --> C
    
    D[Lowest level] --> A
    E[Middle level] --> B
    F[Highest level] --> C

Design

We want to implement support for 2 modes. When no progress value is added, the default is a 1/4 circular loading ring with a rotation animation (like the animation in the image above). When a progress value is added, it displays the progress value.

// Mode 1: Rotation animation (default)
CircularProgress::new()  // Shows a rotating 90-degree arc

// Mode 2: Progress display
CircularProgress::new().value(0.75)  // Shows 75% progress

This design allows a single component to satisfy two different use cases, avoiding the complexity of creating multiple similar components.

Graphics Drawing

When there is no progress bar, it’s a rotating 1/4 ring with rounded ends. We can implement this using canvas path drawing.

fn draw_thick_quarter_arc(
    x: Pixels,
    y: Pixels,
    radius: Pixels,
    rotation_angle: f32,
) -> Path<Pixels> {
    let center = point(x, y);
    let mut builder = PathBuilder::stroke(px(4.0)).with_style(PathStyle::Stroke(
        StrokeOptions::default()
            .with_line_cap(lyon_path::LineCap::Round)
            .with_line_width(4.0),
    ));

    // Calculate the rotated starting point
    let start_angle = rotation_angle.to_radians();
    let start_x = center.x + radius * start_angle.cos();
    let start_y = center.y + radius * start_angle.sin();

    // Move to the rotated starting point
    builder.move_to(point(start_x, start_y));

    // Calculate the rotated ending point (90-degree arc)
    let end_angle = (rotation_angle + 90.0).to_radians();
    let end_x = center.x + radius * end_angle.cos();
    let end_y = center.y + radius * end_angle.sin();

    // Draw the 90-degree arc
    builder.arc_to(
        point(radius, radius),
        px(0.0),
        false, // small arc
        true,  // clockwise
        point(end_x, end_y),
    );

    builder.build().unwrap() // Build the path
}

The key points are using with_line_cap to achieve rounded ends, using with_line_width to control ring thickness, and using arc_to to create the arc.

When there is a progress bar, we need to control the arc degree of the ring based on the progress value. So we need to convert the progress value from 0-1 to degrees 0-360.

    // Calculate the angle corresponding to progress (360 degrees * progress value)
    let progress_angle = 360.0 * progress.clamp(0.0, 1.0);

Since the arc is implemented using the arc_to method, we need to know the start point, end point, and rotation angle of the arc. Generally, we take the 12 o’clock direction as 0 degrees and increase clockwise.

So the starting point is:

    // Fixed starting angle at -90 degrees (starting from 12 o'clock direction)
    let start_angle: f32 = -90.0;
    let start_x = center.x + radius * start_angle.to_radians().cos();
    let start_y = center.y + radius * start_angle.to_radians().sin();

The ending point is:

    // Calculate the ending point
    let end_angle = start_angle + progress_angle;
    let end_x = center.x + radius * end_angle.to_radians().cos();
    let end_y = center.y + radius * end_angle.to_radians().sin();

The overall code is as follows:

// Add new drawing function
fn draw_progress_arc(
    x: Pixels,
    y: Pixels,
    radius: Pixels,
    progress: f32, // Progress value, range 0.0 to 1.0
) -> Path<Pixels> {
    let center = point(x, y);
    let mut builder = PathBuilder::stroke(px(4.0)).with_style(PathStyle::Stroke(
        StrokeOptions::default()
            .with_line_cap(lyon_path::LineCap::Round)
            .with_line_width(4.0),
    ));

    // Calculate the angle corresponding to progress (360 degrees * progress value)
    let progress_angle = 360.0 * progress.clamp(0.0, 1.0);

    // Fixed starting angle at -90 degrees (starting from 12 o'clock direction)
    let start_angle: f32 = -90.0;
    let start_x = center.x + radius * start_angle.to_radians().cos();
    let start_y = center.y + radius * start_angle.to_radians().sin();

    // Move to starting point
    builder.move_to(point(start_x, start_y));

    // Calculate the ending point
    let end_angle = start_angle + progress_angle;
    let end_x = center.x + radius * end_angle.to_radians().cos();
    let end_y = center.y + radius * end_angle.to_radians().sin();

    // Draw the progress arc
    builder.arc_to(
        point(radius, radius),
        px(0.0),
        progress_angle > 180.0, // Use large arc when angle is greater than 180 degrees
        true,                   // clockwise
        point(end_x, end_y),
    );

    builder.build().unwrap()
}

Mainly using trigonometric functions to accurately calculate the arc position.

Text Rendering

When displaying specific progress, the component displays a percentage text at the center of the circle:

// Display percentage value at center of circle
if let Some(value) = self.value {
    let percentage_text = format!("{:.0}%", value * 100.0);

    // Precise text centering calculation
    let text_width = shaped_line.width;
    let text_height = font_size * 1.2;
    let text_start_x = text_center_x - text_width / 2.0;
    let text_start_y = text_center_y - text_height / 2.0;

    // Use GPUI text system to render
    let _ = shaped_line.paint(text_origin, font_size, window, _cx);
}

Animation System

We need an animation state manager to manage the state of the animation.

// Animation state structure
#[derive(Clone)]
struct AnimationState {
    start: Instant,
    rotation_angle: f32,
}

The component uses GPUI’s element state management to implement smooth animations:

fn request_layout(
        &mut self,
        global_id: Option<&GlobalElementId>,
        _local_id: Option<&InspectorElementId>,
        window: &mut Window,
        cx: &mut App,
    ) -> (LayoutId, Self::RequestLayoutState) {
        // Use gpui's element state management to ensure animation continues running
        if let Some(global_id) = global_id {
            window.with_element_state(global_id, |state, window| {
                let mut state = state.unwrap_or_else(|| AnimationState {
                    start: Instant::now(),
                    rotation_angle: 0.0,
                });

                // Update rotation angle
                let now = Instant::now();
                let delta = now.duration_since(state.start).as_secs_f32();
                state.rotation_angle = (delta * 360.0/*rotate 360 degrees per second*/) % 360.0;

                // Update component's rotation angle
                self.rotation_angle = state.rotation_angle;

                let (circular_size, _stroke_width) = self.size.get_size();

                // If there's a label, need additional height space
                let total_height = if self.label.is_some() {
                    circular_size + 25.0 // label height
                } else {
                    circular_size
                };

                // Create style
                let style = Style {
                    size: Size::new(px(circular_size).into(), px(total_height).into()),
                    ..Default::default()
                };

                // Return layout ID and state
                let layout_id = window.request_layout(style, None, cx);

                // Request next frame animation
                window.request_animation_frame();

                ((layout_id, ()), state)
            })
        } else {
            // If no global ID, use simple implementation
            let now = Instant::now();
            if let Some(last_update) = self.last_update {
                let delta = now.duration_since(last_update).as_secs_f32();
                self.rotation_angle = (self.rotation_angle + delta * 180.0) % 360.0;
            }
            self.last_update = Some(now);

            let (circular_size, _stroke_width) = self.size.get_size();

            // Create style
            let style = Style {
                size: Size::new(px(circular_size).into(), px(circular_size).into()),
                ..Default::default()
            };

            let layout_id = window.request_layout(style, None, cx);
            window.request_animation_frame();

            (layout_id, ())
        }
    }

The final result is as follows:

Result image