Skip to content

gpui: Fix bottom-aligned scroll bar disappearing#51223

Merged
mikayla-maki merged 7 commits intozed-industries:mainfrom
feitreim:bugfix-gpui-bottom-list-scroll
Mar 25, 2026
Merged

gpui: Fix bottom-aligned scroll bar disappearing#51223
mikayla-maki merged 7 commits intozed-industries:mainfrom
feitreim:bugfix-gpui-bottom-list-scroll

Conversation

@feitreim
Copy link
Copy Markdown
Contributor

Closes #51198

This actually doesn't effect any of the scrollbars in Zed, as they have a separate handler that prevents this issue from occurring
in crates/ui/src/components/scrollbar.rs, line 856

let current_offset = current_offset
      .along(axis)
      .clamp(-max_offset, Pixels::ZERO)
      .abs();

so it is gpui specific. I still added a test case and I have a manual test script:

Details

//! Reproduction of the scrollbar-offset bug in bottom-aligned `ListState`.
//!
//! Run with: cargo run -p gpui --example list_bottom_scrollbar_bug
//!
//! The list starts pinned to the bottom. Before the fix, the red scrollbar
//! thumb was pushed off the bottom of the track (invisible).
use gpui::{
    App, Bounds, Context, ListAlignment, ListState, Window, WindowBounds, WindowOptions, div, list,
    prelude::*, px, rgb, size,
};
use gpui_platform::application;

const ITEM_COUNT: usize = 40;
const COLORS: [u32; 4] = [0xE8F0FE, 0xFCE8E6, 0xE6F4EA, 0xFEF7E0];

struct BugRepro {
    list_state: ListState,
}

impl BugRepro {
    fn new() -> Self {
        let list_state = ListState::new(ITEM_COUNT, ListAlignment::Bottom, px(5000.));
        Self { list_state }
    }
}

impl Render for BugRepro {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        let state = &self.list_state;

        let max_offset = state.max_offset_for_scrollbar().y;
        let raw_offset = -state.scroll_px_offset_for_scrollbar().y;
        let viewport_h = state.viewport_bounds().size.height;
        let content_h = max_offset + viewport_h;

        let thumb_h = if content_h > px(0.) {
            ((viewport_h / content_h) * viewport_h).max(px(20.))
        } else {
            viewport_h
        };

        let thumb_top = if max_offset > px(0.) {
            (raw_offset / max_offset) * (viewport_h - thumb_h)
        } else {
            px(0.)
        };

        div()
            .size_full()
            .flex()
            .flex_row()
            .bg(rgb(0xffffff))
            .text_color(rgb(0x333333))
            .text_xl()
            .child(
                div().flex_1().h_full().child(
                    list(state.clone(), |ix, _window, _cx| {
                        let height = if ix % 4 == 0 { px(70.) } else { px(40.) };
                        let bg = COLORS[ix % COLORS.len()];
                        div()
                            .h(height)
                            .w_full()
                            .bg(rgb(bg))
                            .border_b_1()
                            .border_color(rgb(0xcccccc))
                            .px_2()
                            .flex()
                            .items_center()
                            .child(format!("Item {ix}"))
                            .into_any()
                    })
                    .h_full()
                    .w_full(),
                ),
            )
            .child(
                div()
                    .w(px(14.))
                    .h_full()
                    .bg(rgb(0xe0e0e0))
                    .relative()
                    .child(
                        div()
                            .absolute()
                            .right(px(0.))
                            .top(thumb_top)
                            .h(thumb_h)
                            .w(px(14.))
                            .rounded_sm()
                            .bg(rgb(0xff3333)),
                    ),
            )
    }
}

fn main() {
    application().run(|cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |_, cx| cx.new(|_| BugRepro::new()),
        )
        .unwrap();
        cx.activate(true);
    });
}

where I was able to test it out, here is a video of the new working behavior.

Screen.Recording.2026-03-10.at.3.48.15.PM.mov

Release Notes:

  • gpui: fixed a bug where the scollbar would disappear when using a bottom aligned list.

@cla-bot cla-bot bot added the cla-signed The user has signed the Contributor License Agreement label Mar 10, 2026
@zed-community-bot zed-community-bot bot added the first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions label Mar 10, 2026
@feitreim
Copy link
Copy Markdown
Contributor Author

Would love to talk to someone about this! the interaction here between gpui and zed itself has me a bit confused on if this change is necessary, if more changes should happen, if the code that prevents this behavior from occuring in zed should be changed or not, all has me a bit confused about what exactly the best course is.

@maxdeviant maxdeviant changed the title gpui: fix bottom aligned scroll bar disappearing gpui: Fix bottom-aligned scroll bar disappearing Mar 10, 2026
@suxiaoshao
Copy link
Copy Markdown

I happened to discover this bug while developing an app with gpui. I asked Codex to check whether it was my problem or an issue with gpui, and it quickly identified the cause. I then had it draft an issue. I didn’t expect the Zed team to fix it so quickly, and I hope this PR can be merged soon.

@SomeoneToIgnore SomeoneToIgnore added the area:gpui GPUI rendering framework support label Mar 17, 2026
@zelenenka zelenenka added the guild Pull requests by someone in Zed Guild. NOTE: the label application is automated via github actions label Mar 19, 2026
@feitreim feitreim force-pushed the bugfix-gpui-bottom-list-scroll branch from 406e8a1 to 672f53f Compare March 24, 2026 16:37
@feitreim
Copy link
Copy Markdown
Contributor Author

Cleaner Example Script:

Details

#![cfg_attr(target_family = "wasm", no_main)]

//! Reproduction for https://github.com/zed-industries/zed/issues/51198
//!
//! A bottom-aligned list with a scrollbar. The bug: when the list is pinned
//! to the bottom (the default for `ListAlignment::Bottom`),
//! `scroll_px_offset_for_scrollbar()` returns the full content height instead
//! of `content_height - viewport_height`, so the unclamped fraction exceeds
//! 1.0 and the thumb renders off the visible track.
//!
//! This example deliberately does NOT clamp the thumb position so the bug is
//! visible: on a buggy build the thumb will be pushed below the track. The
//! diagnostic line at the top shows the raw fraction — it should be 1.0 when
//! pinned to the bottom, but reads >1.0 on the unfixed code.
//!
//! Run with: cargo run -p gpui --example bottom_list_scrollbar

use gpui::{
    App, Bounds, Context, ListAlignment, ListState, Render, Window, WindowBounds, WindowOptions,
    div, list, prelude::*, px, rgb, size,
};
use gpui_platform::application;

const ITEM_COUNT: usize = 40;
const SCROLLBAR_WIDTH: f32 = 12.;

struct BottomListDemo {
    list_state: ListState,
}

impl BottomListDemo {
    fn new() -> Self {
        Self {
            list_state: ListState::new(ITEM_COUNT, ListAlignment::Bottom, px(500.)).measure_all(),
        }
    }
}

impl Render for BottomListDemo {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        let max_offset = self.list_state.max_offset_for_scrollbar().y;
        let current_offset = -self.list_state.scroll_px_offset_for_scrollbar().y;

        let viewport_height = self.list_state.viewport_bounds().size.height;

        let raw_fraction = if max_offset > px(0.) {
            current_offset / max_offset
        } else {
            0.
        };

        let total_height = viewport_height + max_offset;
        let thumb_height = if total_height > px(0.) {
            px(viewport_height.as_f32() * viewport_height.as_f32() / total_height.as_f32())
                .max(px(30.))
        } else {
            px(30.)
        };

        let track_space = viewport_height - thumb_height;
        // Intentionally unclamped so the bug is visible.
        let thumb_top = track_space * raw_fraction;

        let bug_detected = raw_fraction > 1.0;

        div()
            .size_full()
            .bg(rgb(0xFFFFFF))
            .flex()
            .flex_col()
            .p_4()
            .gap_2()
            .child(
                div()
                    .text_sm()
                    .flex()
                    .flex_col()
                    .gap_1()
                    .child(format!(
                        "offset: {:.0} / max: {:.0} | fraction: {:.3}",
                        current_offset.as_f32(),
                        max_offset.as_f32(),
                        raw_fraction,
                    ))
                    .child(
                        div()
                            .text_color(if bug_detected {
                                rgb(0xCC0000)
                            } else {
                                rgb(0x008800)
                            })
                            .child(if bug_detected {
                                format!(
                                    "BUG: fraction is {:.3} (> 1.0) — thumb is off-track!",
                                    raw_fraction
                                )
                            } else {
                                "OK: fraction <= 1.0 — thumb is within track.".to_string()
                            }),
                    ),
            )
            .child(
                div()
                    .flex_1()
                    .flex()
                    .flex_row()
                    .overflow_hidden()
                    .border_1()
                    .border_color(rgb(0xCCCCCC))
                    .rounded_sm()
                    .child(
                        list(self.list_state.clone(), |index, _window, _cx| {
                            let height = px(30. + (index % 5) as f32 * 10.);
                            div()
                                .h(height)
                                .w_full()
                                .flex()
                                .items_center()
                                .px_3()
                                .border_b_1()
                                .border_color(rgb(0xEEEEEE))
                                .bg(if index % 2 == 0 {
                                    rgb(0xFAFAFA)
                                } else {
                                    rgb(0xFFFFFF)
                                })
                                .text_sm()
                                .child(format!("Item {index}"))
                                .into_any()
                        })
                        .flex_1(),
                    )
                    // Scrollbar track
                    .child(
                        div()
                            .w(px(SCROLLBAR_WIDTH))
                            .h_full()
                            .flex_shrink_0()
                            .bg(rgb(0xE0E0E0))
                            .relative()
                            .child(
                                // Thumb — position is unclamped to expose the bug
                                div()
                                    .absolute()
                                    .top(thumb_top)
                                    .w_full()
                                    .h(thumb_height)
                                    .bg(if bug_detected {
                                        rgb(0xCC0000)
                                    } else {
                                        rgb(0x888888)
                                    })
                                    .rounded_sm(),
                            ),
                    ),
            )
    }
}

fn run_example() {
    application().run(|cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx);
        cx.open_window(
            WindowOptions {
                focus: true,
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |_, cx| cx.new(|_| BottomListDemo::new()),
        )
        .unwrap();
        cx.activate(true);
    });
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run_example();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    run_example();
}

@feitreim
Copy link
Copy Markdown
Contributor Author

@mikayla-maki removed the comments, added the example to the pr and just named the values in the test.

@feitreim feitreim requested a review from mikayla-maki March 25, 2026 19:28
@github-actions github-actions bot added Size M and removed Size S labels Mar 25, 2026
@mikayla-maki mikayla-maki enabled auto-merge (squash) March 25, 2026 22:19
@mikayla-maki mikayla-maki merged commit ce0848a into zed-industries:main Mar 25, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:gpui GPUI rendering framework support cla-signed The user has signed the Contributor License Agreement first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions guild Pull requests by someone in Zed Guild. NOTE: the label application is automated via github actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gpui: bottom-aligned ListState scrollbar thumb disappears at the end

6 participants