From 39971e9e8955d0839a987e09fe4476014497c953 Mon Sep 17 00:00:00 2001 From: Alex S Date: Tue, 1 Sep 2026 22:53:32 +0800 Subject: [PATCH 1/2] resizable: settle layout after container resize --- crates/base/src/resizable/mod.rs | 53 ++++++++++++++++++++++++++++-- crates/base/src/resizable/panel.rs | 9 ++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/base/src/resizable/mod.rs b/crates/base/src/resizable/mod.rs index f4e4c52721..eefd7419f0 100644 --- a/crates/base/src/resizable/mod.rs +++ b/crates/base/src/resizable/mod.rs @@ -400,12 +400,61 @@ mod tests { use gpui::{ AppContext as _, Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, - ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, - point, px, size, + ParentElement as _, Pixels, Render, Styled as _, TestAppContext, VisualTestContext, Window, + div, point, px, size, }; use super::{ResizableState, h_resizable, resizable_panel}; + struct MixedSizingHarness { + width: Pixels, + } + + impl Render for MixedSizingHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().w(self.width).h(px(100.)).child( + h_resizable("mixed-sizing") + .child( + resizable_panel() + .size(px(240.)) + .child(div().size_full().debug_selector(|| "fixed-sidebar".into())), + ) + .child( + resizable_panel().child( + div() + .size_full() + .debug_selector(|| "flexible-content".into()), + ), + ), + ) + } + } + + #[gpui::test] + fn mixed_sizing_is_stable_between_resize_and_followup_frame(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, _| MixedSizingHarness { width: px(800.) }); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + }); + let before = cx.debug_bounds("fixed-sidebar").unwrap().size.width; + + view.update(cx, |view, cx| { + view.width = px(1200.); + cx.notify(); + }); + cx.run_until_parked(); + let settled_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width; + cx.update(|window, cx| window.draw(cx).clear(cx)); + let followup_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width; + + // Resizable panels preserve their proportional sizing across a + // container resize; the important invariant is that applying the + // state on the follow-up frame does not move the divider again. + assert_ne!(settled_frame, before); + assert_eq!(followup_frame, settled_frame); + } + struct ResizableHarness { state: gpui::Entity, resizes: Rc>, diff --git a/crates/base/src/resizable/panel.rs b/crates/base/src/resizable/panel.rs index 8cacd7dc0f..0cf553ed71 100644 --- a/crates/base/src/resizable/panel.rs +++ b/crates/base/src/resizable/panel.rs @@ -174,7 +174,7 @@ impl RenderOnce for ResizablePanelGroup { ) .on_prepaint({ let state = state.clone(); - move |bounds, _, cx| { + move |bounds, window, cx| { state.update(cx, |state, cx| { let size_changed = state.bounds.size.along(self.axis) != bounds.size.along(self.axis); @@ -183,6 +183,13 @@ impl RenderOnce for ResizablePanelGroup { if size_changed { state.adjust_to_container_size(cx); + // Size adjustment happens after this frame's layout has + // already been computed. A refresh requested during the + // draw can be coalesced into that same draw, so defer it + // until the current effect cycle has completed. Otherwise + // the settling frame can remain pending until pointer input + // triggers a repaint, making the divider jump on hover. + window.defer(cx, |window, _| window.refresh()); } }) } From f6198f2e9e4edb3a4188516a3f6b0e11ee5c603f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 4 Sep 2026 21:27:52 +0800 Subject: [PATCH 2/2] resizable: Settle the layout with a targeted notify instead of a window refresh `window.refresh()` sets `Window::refreshing`, which disables the view element cache for the whole window, so every `.cached(...)` view re-renders and re-lays-out on the settling frame. The dock renders its tab panels that way and builds its splits from resizable groups, so the cost lands exactly where resizables are used most. A deferred `cx.notify()` on the state entity schedules the same settling frame without touching that cache: `invalidate_view` marks the window dirty once the draw has finished, and a cached view still re-renders when its own bounds change. Measured on a `.cached(...)` sibling view, a container resize goes from one forced re-render to none. The comment is corrected too. A refresh raised mid-draw is not coalesced into that draw, it is dropped by the `not_drawing()` guard; and a notify raised mid-draw records the view as dirty without scheduling a frame, which is why the deferral is what makes either one work. Adds a regression test for the `with_state` path the dock takes, which has no `use_keyed_state` observer behind it to turn a state change into a redraw. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UezrY94M8EFhB5r1w1Rn3S --- crates/base/src/resizable/mod.rs | 50 ++++++++++++++++++++++++++++++ crates/base/src/resizable/panel.rs | 19 +++++++----- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/crates/base/src/resizable/mod.rs b/crates/base/src/resizable/mod.rs index eefd7419f0..1f3f9ed8a7 100644 --- a/crates/base/src/resizable/mod.rs +++ b/crates/base/src/resizable/mod.rs @@ -455,6 +455,56 @@ mod tests { assert_eq!(followup_frame, settled_frame); } + struct CallerStateHarness { + width: Pixels, + state: gpui::Entity, + } + + impl Render for CallerStateHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().w(self.width).h(px(100.)).child( + h_resizable("caller-state") + .with_state(&self.state) + .child( + resizable_panel() + .size(px(240.)) + .child(div().size_full().debug_selector(|| "cs-sidebar".into())), + ) + .child(resizable_panel().child(div().size_full())), + ) + } + } + + /// A group whose state the caller owns (`with_state`, as the dock does) + /// has no `use_keyed_state` observer behind it, so the settling frame has + /// to be scheduled by the deferred notify rather than by that observer. + #[gpui::test] + fn caller_owned_state_settles_on_the_same_frame(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| ResizableState::default())); + let (view, cx) = cx.add_window_view({ + let state = state.clone(); + move |_, _| CallerStateHarness { + width: px(800.), + state, + } + }); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + }); + + view.update(cx, |view, cx| { + view.width = px(1200.); + cx.notify(); + }); + cx.run_until_parked(); + let settled = cx.debug_bounds("cs-sidebar").unwrap().size.width; + cx.update(|window, cx| window.draw(cx).clear(cx)); + let followup = cx.debug_bounds("cs-sidebar").unwrap().size.width; + + assert_eq!(followup, settled, "settling frame must not be pending"); + } + struct ResizableHarness { state: gpui::Entity, resizes: Rc>, diff --git a/crates/base/src/resizable/panel.rs b/crates/base/src/resizable/panel.rs index 0cf553ed71..d4032077f7 100644 --- a/crates/base/src/resizable/panel.rs +++ b/crates/base/src/resizable/panel.rs @@ -183,13 +183,18 @@ impl RenderOnce for ResizablePanelGroup { if size_changed { state.adjust_to_container_size(cx); - // Size adjustment happens after this frame's layout has - // already been computed. A refresh requested during the - // draw can be coalesced into that same draw, so defer it - // until the current effect cycle has completed. Otherwise - // the settling frame can remain pending until pointer input - // triggers a repaint, making the divider jump on hover. - window.defer(cx, |window, _| window.refresh()); + // The adjustment lands after this frame's layout has + // already been computed, and a notify raised during a + // draw only records the view as dirty without scheduling + // a frame for it. Defer the notify so it runs once the + // draw has finished and can schedule the settling frame. + // Otherwise that frame stays pending until some later + // input repaints the window, and the divider appears to + // jump on hover. + let state = cx.entity(); + window.defer(cx, move |_, cx| { + state.update(cx, |_, cx| cx.notify()); + }); } }) }