diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 776aa0d..0a31884 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: - uses: Swatinem/rust-cache@e9d7b9f1e587218970671bac74e5511ed9506e6a - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 with: - command: advisories + command: check advisories - uses: taiki-e/install-action@97888a140839b7239d62fbf6af4e20adb5be3666 with: tool: cargo-audit diff --git a/crates/fluent-layout/src/drag_drop.rs b/crates/fluent-layout/src/drag_drop.rs new file mode 100644 index 0000000..d5d1a70 --- /dev/null +++ b/crates/fluent-layout/src/drag_drop.rs @@ -0,0 +1,408 @@ +//! Typed drag-and-drop contracts for actions that cross pane boundaries. +//! +//! The contract deliberately contains no application-specific payloads. A +//! caller chooses its payload type and supplies the policy for accepting it; +//! this keeps mail, file-browser, and other clients independent while still +//! giving them the same pointer and keyboard affordances. + +use std::{any::Any, fmt, sync::Arc}; + +use fluent_core::ThemeProvider; +use gpui::{ + prelude::*, App, Div, ElementId, Entity, FocusHandle, KeyDownEvent, Pixels, Point, Render, + Stateful, Window, +}; + +/// The operation requested by a drag gesture. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DragOperation { + /// Move the source (the default operation). + #[default] + Move, + /// Copy the source. On Windows and Linux this is requested with Ctrl. + Copy, +} + +impl DragOperation { + /// Derive the operation from the current platform modifiers. + pub fn from_modifiers(modifiers: gpui::Modifiers) -> Self { + if modifiers.control || modifiers.platform { + Self::Copy + } else { + Self::Move + } + } +} + +/// A typed value offered by a [`drag_source`]. +#[derive(Clone)] +pub struct DragPayload { + /// The application-owned value being dragged. + pub value: T, + on_cancel: Option>>, +} + +type CancelHandler = dyn Fn(&T, &mut Window, &mut App) + 'static; + +impl DragPayload { + /// Create a payload from an application value. + pub fn new(value: T) -> Self { + Self { + value, + on_cancel: None, + } + } + + fn with_cancel_handler( + value: T, + on_cancel: impl Fn(&T, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + value, + on_cancel: Some(Arc::new(on_cancel)), + } + } + + fn notify_cancelled(&self, window: &mut Window, cx: &mut App) { + if let Some(on_cancel) = &self.on_cancel { + on_cancel(&self.value, window, cx); + } + } +} + +impl fmt::Debug for DragPayload { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DragPayload") + .field("value", &self.value) + .finish_non_exhaustive() + } +} + +impl PartialEq for DragPayload { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +impl Eq for DragPayload {} + +/// A reusable policy for deciding whether a payload may be dropped. +pub struct DropPolicy { + accepts: Arc bool + 'static>, +} + +impl DropPolicy { + /// Construct a policy. Returning `false` rejects the drop and its feedback. + pub fn new(accepts: impl Fn(&T) -> bool + 'static) -> Self { + Self { + accepts: Arc::new(accepts), + } + } + + /// Test a typed value without involving a window or a drag gesture. + pub fn accepts(&self, value: &T) -> bool { + (self.accepts)(value) + } +} + +/// Add a typed drag source to an existing GPUI element. +/// +/// `ghost` creates the view shown below the pointer while dragging. The +/// payload remains type checked by GPUI: a target registered for another type +/// cannot accept it. `on_cancel` is called when a target accepts the payload +/// but its drop handler declines the operation. +pub fn drag_source( + element: Div, + id: impl Into, + payload: T, + ghost: impl Fn(&T, Point, &mut Window, &mut App) -> Entity + 'static, +) -> Stateful
+where + T: 'static, + W: Render + 'static, +{ + drag_source_with_cancel(element, id, payload, ghost, |_value, _window, _cx| {}) +} + +/// Add a typed drag source and receive a callback when an accepted target +/// declines the drop. The callback is the place for an application to restore +/// any pending source state; the primitive never mutates application data. +pub fn drag_source_with_cancel( + element: Div, + id: impl Into, + payload: T, + ghost: impl Fn(&T, Point, &mut Window, &mut App) -> Entity + 'static, + on_cancel: impl Fn(&T, &mut Window, &mut App) + 'static, +) -> Stateful
+where + T: 'static, + W: Render + 'static, +{ + element + .id(id) + .on_drag( + DragPayload::with_cancel_handler(payload, on_cancel), + move |payload, offset, window, cx| ghost(&payload.value, offset, window, cx), + ) + .aria_description("Drag to move; hold Ctrl while dragging to copy") +} + +/// Add a typed drop target to an existing GPUI element. +/// +/// The target highlights only while an acceptable payload is over it. A drop +/// handler returning `false` cancels the operation, allowing asynchronous or +/// otherwise unavailable destinations to reject it at the contract boundary. +/// The same handler is invoked by Enter or Space when `keyboard_payload` is +/// provided, making the pointer action available without a mouse. +pub fn drop_target( + element: Div, + id: impl Into, + focus_handle: FocusHandle, + focus_ring: gpui::Hsla, + policy: DropPolicy, + on_drop: impl Fn(&T, DragOperation, &mut Window, &mut App) -> bool + 'static, + keyboard_payload: Option, +) -> Stateful
+where + T: Clone + 'static, +{ + let policy_for_drop = Arc::clone(&policy.accepts); + let policy_for_over = Arc::clone(&policy.accepts); + let policy_for_key = Arc::clone(&policy.accepts); + let on_drop = Arc::new(on_drop); + let keyboard_payload = Arc::new(keyboard_payload); + let on_drop_for_key = Arc::clone(&on_drop); + let payload_for_key = Arc::clone(&keyboard_payload); + + element + .id(id) + // Keep the border in the normal style as well as in the drag-over + // style. Refining it only while dragging changes the measured bounds + // for border-box layouts. + .border_2() + .border_color(gpui::transparent_black()) + .tab_index(0) + .track_focus(&focus_handle) + .focus_visible(move |style| style.border_color(focus_ring)) + .aria_label("Drop target") + .aria_description("Press Enter or Space to perform the move") + .aria_keyshortcuts("Enter Space") + .drag_over::>(move |style, payload, _, cx| { + if policy_for_over(&payload.value) { + style.border_color(cx.theme().colors.accent) + } else { + style + } + }) + .can_drop({ + let policy_for_can_drop = Arc::clone(&policy_for_drop); + move |value, _, _| { + value + .downcast_ref::>() + .is_some_and(|payload| policy_for_can_drop(&payload.value)) + } + }) + .on_drop(move |payload: &DragPayload, window, cx| { + if policy_for_drop(&payload.value) { + let completed = on_drop( + &payload.value, + DragOperation::from_modifiers(window.modifiers()), + window, + cx, + ); + if !completed { + payload.notify_cancelled(window, cx); + } + } + }) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + if let Some(payload) = payload_for_key.as_ref() { + if policy_for_key(payload) { + on_drop_for_key( + payload, + DragOperation::from_modifiers(window.modifiers()), + window, + cx, + ); + } + } + } + }) +} + +/// Return whether a dynamically typed GPUI drag contains the requested type. +pub fn is_payload(value: &dyn Any) -> bool { + value.downcast_ref::>().is_some() +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use fluent_test_utils::{open_fluent_test_window, FluentInteractionExt as _}; + use gpui::{div, px, Context, MouseButton, TestAppContext}; + + use super::*; + + struct TestGhost; + + impl Render for TestGhost { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size(px(8.0)) + } + } + + struct DragDropHarness { + first_focus: FocusHandle, + second_focus: FocusHandle, + first_drops: Arc, + second_drops: Arc, + cancelled: Arc, + } + + impl Render for DragDropHarness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let first_drops = Arc::clone(&self.first_drops); + let second_drops = Arc::clone(&self.second_drops); + let cancelled = Arc::clone(&self.cancelled); + let colors = &cx.theme().colors; + + div() + .flex() + .gap_2() + .child( + drag_source_with_cancel( + div().size(px(40.0)).debug_selector(|| "drag-source".into()), + "test-drag-source", + 7_u32, + |_, _, _, cx| cx.new(|_| TestGhost), + move |_, _, _| { + cancelled.fetch_add(1, Ordering::SeqCst); + }, + ) + .child("source"), + ) + .child( + drop_target( + div() + .size(px(64.0)) + .debug_selector(|| "first-target".into()), + "test-drop-target-one", + self.first_focus.clone(), + colors.focus_ring, + DropPolicy::new(|value: &u32| *value == 7), + move |_, _, _, _| { + first_drops.fetch_add(1, Ordering::SeqCst); + false + }, + Some(7), + ) + .child("first"), + ) + .child( + drop_target( + div() + .size(px(64.0)) + .debug_selector(|| "second-target".into()), + "test-drop-target-two", + self.second_focus.clone(), + colors.focus_ring, + DropPolicy::new(|value: &u32| *value == 7), + move |_, _, _, _| { + second_drops.fetch_add(1, Ordering::SeqCst); + true + }, + Some(7), + ) + .child("second"), + ) + } + } + + fn harness(cx: &mut Context) -> DragDropHarness { + DragDropHarness { + first_focus: cx.focus_handle().tab_stop(true), + second_focus: cx.focus_handle().tab_stop(true), + first_drops: Arc::default(), + second_drops: Arc::default(), + cancelled: Arc::default(), + } + } + + #[test] + fn payloads_are_typed() { + let payload = DragPayload::new(42_u32); + assert!(is_payload::(&payload)); + assert!(!is_payload::(&payload)); + } + + #[test] + fn policy_accepts_and_rejects_values() { + let policy = DropPolicy::new(|value: &u32| *value > 10); + assert!(policy.accepts(&11)); + assert!(!policy.accepts(&10)); + } + + #[test] + fn modifiers_choose_copy_or_move() { + assert_eq!( + DragOperation::from_modifiers(gpui::Modifiers::default()), + DragOperation::Move + ); + assert_eq!( + DragOperation::from_modifiers(gpui::Modifiers { + control: true, + ..Default::default() + }), + DragOperation::Copy + ); + } + + #[gpui::test] + fn distinct_targets_receive_keyboard_drops(cx: &mut TestAppContext) { + let (view, cx) = open_fluent_test_window(cx, |_, cx| harness(cx)); + let (first, second, first_focus, second_focus) = view.read_with(cx, |view, _| { + ( + Arc::clone(&view.first_drops), + Arc::clone(&view.second_drops), + view.first_focus.clone(), + view.second_focus.clone(), + ) + }); + + cx.update(|window, app| window.focus(&first_focus, app)); + cx.type_keys("enter"); + cx.update(|window, app| window.focus(&second_focus, app)); + cx.type_keys("enter"); + + assert_eq!(first.load(Ordering::SeqCst), 1); + assert_eq!(second.load(Ordering::SeqCst), 1); + } + + #[gpui::test] + fn declined_pointer_drop_notifies_source_and_keeps_bounds(cx: &mut TestAppContext) { + let (view, cx) = open_fluent_test_window(cx, |_, cx| harness(cx)); + let (drops, cancelled) = view.read_with(cx, |view, _| { + (Arc::clone(&view.first_drops), Arc::clone(&view.cancelled)) + }); + let source = cx.selector_bounds("drag-source").unwrap(); + let before = cx.selector_bounds("first-target").unwrap(); + + cx.simulate_mouse_down(source.center(), MouseButton::Left, gpui::Modifiers::none()); + cx.simulate_mouse_move( + before.center(), + Some(MouseButton::Left), + gpui::Modifiers::none(), + ); + cx.run_until_parked(); + let during = cx.selector_bounds("first-target").unwrap(); + cx.simulate_mouse_up(before.center(), MouseButton::Left, gpui::Modifiers::none()); + cx.run_until_parked(); + + assert_eq!(during, before, "drag-over feedback must not shift layout"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(cancelled.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/fluent-layout/src/grouped_virtual_list.rs b/crates/fluent-layout/src/grouped_virtual_list.rs new file mode 100644 index 0000000..4d13e46 --- /dev/null +++ b/crates/fluent-layout/src/grouped_virtual_list.rs @@ -0,0 +1,398 @@ +use std::sync::Arc; + +use gpui::{ + list, px, AnyElement, App, ElementId, FocusHandle, InteractiveElement, IntoElement, + KeyDownEvent, ListAlignment, ListSizingBehavior, ListState, ParentElement, RenderOnce, Role, + SharedString, StatefulInteractiveElement, Styled, Window, +}; + +use crate::virtual_list::DEFAULT_OVERSCAN; + +/// A group in a [`GroupedVirtualList`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VirtualListGroup { + /// Stable group identifier. + pub id: SharedString, + /// Text announced and shown in the group header. + pub label: SharedString, + /// Number of rows in this group. + pub item_count: usize, + /// Whether the group starts expanded. + pub expanded: bool, +} + +impl VirtualListGroup { + /// Creates an expanded group. + pub fn new( + id: impl Into, + label: impl Into, + item_count: usize, + ) -> Self { + Self { + id: id.into(), + label: label.into(), + item_count, + expanded: true, + } + } + + /// Sets the initial expansion state. + pub fn expanded(mut self, expanded: bool) -> Self { + self.expanded = expanded; + self + } +} + +type RowBuilder = Box AnyElement + 'static>; +type HeaderBuilder = + Arc AnyElement + 'static>; + +/// A virtual list partitioned into collapsible groups with sticky headers. +/// +/// Group headers are exposed as `group` nodes and rows as `list_item` nodes. +/// Arrow keys move between visible rows; Home and End move to the first and +/// last visible row. Enter and Space toggle the focused group header. +#[derive(IntoElement)] +pub struct GroupedVirtualList { + id: ElementId, + groups: Vec, + build_row: RowBuilder, + header: Option, + estimated_row_height: f32, + header_height: f32, + overscan: f32, +} + +impl GroupedVirtualList { + /// Creates a grouped list from its ordered groups and a row builder. + pub fn new( + id: impl Into, + groups: Vec, + build_row: impl FnMut(usize, usize, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + Self { + id: id.into(), + groups, + build_row: Box::new(build_row), + header: None, + estimated_row_height: 32., + header_height: 36., + overscan: DEFAULT_OVERSCAN, + } + } + + /// Supplies a custom header renderer. The callback receives group index, + /// group data, and its current expansion state. + pub fn header( + mut self, + header: impl Fn(usize, &VirtualListGroup, bool, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + self.header = Some(Arc::new(header)); + self + } + /// Sets the estimated row height. + pub fn row_height(mut self, height: f32) -> Self { + self.estimated_row_height = height.max(1.); + self + } + /// Sets the header height estimate. + pub fn header_height(mut self, height: f32) -> Self { + self.header_height = height.max(1.); + self + } + /// Sets list overscan. + pub fn overscan(mut self, overscan: f32) -> Self { + self.overscan = overscan.max(0.); + self + } +} + +struct GroupedState { + list: ListState, + expanded: Vec, + focus: Vec, + count: usize, + hinted: bool, +} + +impl RenderOnce for GroupedVirtualList { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let groups = self.groups; + let group_count = groups.len(); + let expanded_defaults: Vec = groups.iter().map(|g| g.expanded).collect(); + let estimate = self.estimated_row_height; + let header_height = self.header_height; + let overscan = self.overscan; + let header = self.header; + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| GroupedState { + list: ListState::new(0, ListAlignment::Top, px(overscan)), + expanded: expanded_defaults, + focus: Vec::new(), + count: 0, + hinted: false, + }); + let (list_state, expanded, focus, count) = state.update(cx, |s, cx| { + if s.expanded.len() != group_count { + s.expanded = groups.iter().map(|g| g.expanded).collect(); + } + let count = groups + .iter() + .enumerate() + .map(|(i, g)| 1 + if s.expanded[i] { g.item_count } else { 0 }) + .sum(); + if s.count != count { + s.list.reset_with_uniform_height(count, px(estimate)); + s.count = count; + s.hinted = false; + } + while s.focus.len() < count { + s.focus.push(cx.focus_handle()); + } + s.focus.truncate(count); + (s.list.clone(), s.expanded.clone(), s.focus.clone(), count) + }); + let mut offsets = Vec::with_capacity(group_count); + let mut ix = 0; + for (i, g) in groups.iter().enumerate() { + offsets.push(ix); + ix += 1 + if expanded[i] { g.item_count } else { 0 }; + } + let mut row_builder = self.build_row; + let header_for_rows = header.clone(); + let groups_for_rows = groups.clone(); + let state_for_rows = state.clone(); + let expanded_for_rows = expanded.clone(); + let focus_for_rows = focus.clone(); + let list_for_rows = list_state.clone(); + let build = move |flat: usize, w: &mut Window, app: &mut App| { + let (gi, item) = locate(&groups_for_rows, &expanded_for_rows, flat); + let focus_handle = focus_for_rows[flat].clone(); + let navigation_focus = focus_for_rows.clone(); + let navigation_list = list_for_rows.clone(); + let navigate = move |event: &KeyDownEvent, window: &mut Window, cx: &mut App| { + let target = match event.keystroke.key.as_str() { + "up" => flat.checked_sub(1), + "down" => (flat + 1 < navigation_focus.len()).then_some(flat + 1), + "home" => (!navigation_focus.is_empty()).then_some(0), + "end" => navigation_focus.len().checked_sub(1), + _ => None, + }; + if let Some(target) = target { + navigation_focus[target].focus(window, cx); + navigation_list.scroll_to_reveal_item(target); + } + }; + if item.is_none() { + let group = &groups_for_rows[gi]; + let expanded_now = expanded_for_rows[gi]; + let state = state_for_rows.clone(); + let key_state = state.clone(); + let default = gpui::div() + .id(("group-header", gi as u64)) + .debug_selector(move || format!("group-header-{gi}")) + .role(Role::Group) + .aria_label(group.label.clone()) + .aria_expanded(expanded_now) + .track_focus(&focus_handle) + .tab_index(if flat == 0 { 0 } else { -1 }) + .on_click(move |_, _, cx| { + state.update(cx, |s, cx| { + s.expanded[gi] = !s.expanded[gi]; + cx.notify(); + }); + }) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + key_state.update(cx, |s, cx| { + s.expanded[gi] = !s.expanded[gi]; + cx.notify(); + }); + } else { + navigate(event, window, cx); + } + }); + if let Some(ref make) = header_for_rows { + default + .h(px(header_height)) + .child(make(gi, group, expanded_now, w, app)) + .into_any_element() + } else { + default + .child(default_header(group, expanded_now, header_height)) + .into_any_element() + } + } else if let Some(item) = item { + gpui::div() + .id(("group-row", flat as u64)) + .debug_selector(move || format!("group-row-{flat}")) + .role(Role::ListItem) + .track_focus(&focus_handle) + .tab_index(-1) + .on_key_down(navigate) + .child(row_builder(gi, item, w, app)) + .into_any_element() + } else { + gpui::div().into_any_element() + } + }; + let list_element = list(list_state, build) + .with_sizing_behavior(ListSizingBehavior::Auto) + .size_full(); + let mut root = gpui::div().relative().size_full().child(list_element); + // The first visible group header is repeated at the viewport edge. It + // has no accessibility role, so the source header remains the only + // group node and keyboard/accessibility order stays deterministic. + if let Some(gi) = + sticky_group_index(&offsets, state.read(cx).list.logical_scroll_top().item_ix) + { + let g = groups[gi].clone(); + let expanded_now = expanded[gi]; + let sticky_header = header.clone(); + root = root.child( + gpui::div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(header_height)) + .id(("sticky-group-header", gi as u64)) + .debug_selector(move || format!("sticky-group-header-{gi}")) + .child(if let Some(make) = sticky_header { + make(gi, &g, expanded_now, window, cx) + } else { + default_header(&g, expanded_now, header_height) + }), + ); + } + let _ = count; + root + } +} + +fn sticky_group_index(offsets: &[usize], item: usize) -> Option { + offsets + .iter() + .enumerate() + .rev() + .find(|(_, offset)| **offset <= item) + .map(|(index, _)| index) +} + +fn default_header(group: &VirtualListGroup, expanded: bool, header_height: f32) -> AnyElement { + gpui::div() + .h(px(header_height)) + .flex() + .items_center() + .gap_2() + .px_3() + .child(if expanded { "▾" } else { "▸" }) + .child(group.label.clone()) + .child(format!("({})", group.item_count)) + .into_any_element() +} + +fn locate(groups: &[VirtualListGroup], expanded: &[bool], flat: usize) -> (usize, Option) { + let mut at = 0; + for (i, g) in groups.iter().enumerate() { + if flat == at { + return (i, None); + } + at += 1; + if expanded[i] { + if flat < at + g.item_count { + return (i, Some(flat - at)); + } + at += g.item_count; + } + } + (groups.len().saturating_sub(1), Some(0)) +} + +#[cfg(test)] +fn flattened_count(groups: &[VirtualListGroup], expanded: &[bool]) -> usize { + groups + .iter() + .enumerate() + .map(|(i, group)| 1 + if expanded[i] { group.item_count } else { 0 }) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn flattening_skips_collapsed_items() { + let groups = vec![ + VirtualListGroup::new("a", "A", 3), + VirtualListGroup::new("b", "B", 2), + ]; + assert_eq!(locate(&groups, &[true, false], 0), (0, None)); + assert_eq!(locate(&groups, &[true, false], 3), (0, Some(2))); + assert_eq!(locate(&groups, &[true, false], 4), (1, None)); + } + #[test] + fn sticky_header_tracks_last_group_at_or_before_row() { + assert_eq!(sticky_group_index(&[0, 4, 8], 0), Some(0)); + assert_eq!(sticky_group_index(&[0, 4, 8], 7), Some(1)); + assert_eq!(sticky_group_index(&[0, 4, 8], 100), Some(2)); + } + + #[test] + fn large_group_has_one_header_and_all_rows() { + let groups = [VirtualListGroup::new("large", "Large", 100_000)]; + assert_eq!(flattened_count(&groups, &[true]), 100_001); + assert_eq!(locate(&groups, &[true], 99_999), (0, Some(99_998))); + assert_eq!(locate(&groups, &[true], 100_000), (0, Some(99_999))); + } + + #[gpui::test] + fn toggling_header_rebuilds_visible_rows(cx: &mut gpui::TestAppContext) { + use fluent_test_utils::{open_fluent_test_window, FluentInteractionExt as _}; + use gpui::{Context, Render}; + + struct Host; + impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + GroupedVirtualList::new( + "test-grouped-list", + vec![VirtualListGroup::new("a", "Alpha", 2)], + |_, row, _, _| { + gpui::div() + .h(px(24.)) + .child(format!("row {row}")) + .into_any_element() + }, + ) + } + } + + let (_, cx) = open_fluent_test_window(cx, |_, _| Host); + assert!(cx.selector_bounds("group-row-1").is_some()); + assert!(cx.selector_bounds("group-row-2").is_some()); + cx.click_selector("group-header-0"); + assert!(cx.selector_bounds("group-row-1").is_none()); + assert!(cx.selector_bounds("group-row-2").is_none()); + } + + #[gpui::test] + fn header_enter_toggles_group(cx: &mut gpui::TestAppContext) { + use fluent_test_utils::{open_fluent_test_window, FluentInteractionExt as _}; + use gpui::{Context, Render}; + + struct Host; + impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + GroupedVirtualList::new( + "test-keyboard-grouped-list", + vec![VirtualListGroup::new("a", "Alpha", 1)], + |_, _, _, _| gpui::div().h(px(24.)).into_any_element(), + ) + } + } + + let (_, cx) = open_fluent_test_window(cx, |_, _| Host); + cx.click_selector("group-header-0"); + cx.type_keys("enter"); + assert!(cx.selector_bounds("group-row-1").is_some()); + } +} diff --git a/crates/fluent-layout/src/lib.rs b/crates/fluent-layout/src/lib.rs index 898b1a3..7bed507 100644 --- a/crates/fluent-layout/src/lib.rs +++ b/crates/fluent-layout/src/lib.rs @@ -19,10 +19,14 @@ pub mod dock; /// Public API item. pub mod document_viewer; /// Public API item. +pub mod drag_drop; +/// Public API item. pub mod drawer; /// Public API item. pub mod flyout; /// Public API item. +pub mod grouped_virtual_list; +/// Public API item. pub mod menu_bar; /// Public API item. pub mod menu_button; @@ -70,8 +74,13 @@ pub use data_table::{ pub use dialog::{Dialog, DialogAction}; pub use dock::{DockPanel, DockPosition}; pub use document_viewer::{DocumentViewer, RichDocument, RichDocumentBlock, RichTextRun}; +pub use drag_drop::{ + drag_source, drag_source_with_cancel, drop_target, is_payload, DragOperation, DragPayload, + DropPolicy, +}; pub use drawer::{Drawer, DrawerMode, DrawerPosition, DrawerSize}; pub use flyout::{Flyout, FlyoutPlacement}; +pub use grouped_virtual_list::{GroupedVirtualList, VirtualListGroup}; pub use menu_bar::{MenuBar, MenuItemDef}; pub use menu_button::MenuButton; pub use message_bar::{MessageBar, MessageIntent}; diff --git a/docs/components.md b/docs/components.md index 4979e08..1a03637 100644 --- a/docs/components.md +++ b/docs/components.md @@ -23,6 +23,7 @@ consumers can rely on; “gallery” is the route family in `examples/gallery`. - Dialog — layout; modal/open/closed; Tab is trapped, Escape dismisses; **Status and feedback** - Divider — primitives; horizontal/vertical; no keyboard interaction; **Status and feedback** - DockPanel — layout; docked/floating, collapsed; keyboard follows its child; **Data and lists** +- DragOperation / DragPayload / DropPolicy — layout; typed move/copy payloads, cancellable acceptance, keyboard move; **Data and lists** - DocumentViewer — layout; loading/ready/error, rich blocks; keyboard follows document content; **Data and lists** - Drawer — layout; overlay/push, left/right/top/bottom, sizes; Tab is trapped, Escape closes; **Data and lists** - Dropdown — primitives; open/closed, selected, disabled; arrows navigate, Enter selects, Escape closes; **Selection controls** @@ -30,6 +31,7 @@ consumers can rely on; “gallery” is the route family in `examples/gallery`. - Field — primitives; required/helper/validating/error; Tab enters its control; **Forms** - FilePickerField — app; empty/selected/loading/error; Tab focuses the picker action, Enter/Space opens; **Date and input** - Flyout — layout; hidden/open, placements; Tab follows content, Escape closes; **Status and feedback** +- GroupedVirtualList / VirtualListGroup — layout; virtual variable-height rows with sticky, accessible, expandable group headers; **Data and lists** - Icon — primitives; sizes; not focusable by itself; **Buttons and links** - IconButton — primitives; appearances, disabled, pressed; Enter/Space activates; **Buttons and links** - InfoLabel — primitives; default/with tooltip; Tab focuses the info affordance; **Text and labels** diff --git a/docs/linux-runtime-validation.md b/docs/linux-runtime-validation.md new file mode 100644 index 0000000..1e540ab --- /dev/null +++ b/docs/linux-runtime-validation.md @@ -0,0 +1,36 @@ +# Linux runtime validation + +## 2026-11-03 — WI-1438 headless lane + +Environment: Linux, sway 1.10.1 with its headless backend at 1280x720, +`WGPU_BACKEND=gl`, and Mesa software rendering (`LIBGL_ALWAYS_SOFTWARE=1`). +This validates the Linux/Wayland wgpu path in software; it is **not** an AMD +Vulkan hardware run. Xvfb/Weston were not installed, so X11 was not runnable. +Screenshots and logs were captured outside the repository. + +| Check | Result | Evidence / limitation | +|---|---|---| +| Workspace build | PASS | `cargo build --workspace --all-targets` | +| `hello_world` | PASS | Alive after 4 s; 1280x720 screenshot, 895 colours | +| `gallery` | PASS | Alive after 4 s; screenshot, 7,746 colours | +| `demo_app` | PASS | Alive after 4 s; screenshot, 7,898 colours | +| `widgets` | PASS | Alive after 4 s; screenshot, 5,296 colours | +| `form` | PASS | Alive after 4 s; screenshot, 3,901 colours | +| `screenshots button` | PASS | Alive after 4 s; screenshot, 1,903 colours; fixed-size window did not crash | +| GPUI `video_frame` | PASS (basic) | Alive after 4 s; generated gradient visible; screenshot, 45,507 colours | +| GPUI `scale` | PASS | Alive after 4 s; scaled and unscaled blocks visible; screenshot, 374 colours | +| Video invalid sizes / stale ids | NOT RUN | API checks are covered by tests, but this lane did not add an interactive renderer probe | +| Video alpha vs sprite compositing | NOT RUN | Example is fully opaque | +| Video allocate/upload/free cycles and GPU memory | NOT RUN | No hardware GPU or memory telemetry exposed in this container | +| Overlapping backdrop blur | NOT RUN | `demo_app` currently uses the blur-layer colour token, not `backdrop_blur` | +| Several-blur performance | NOT RUN | No representative blur surface or hardware GPU | +| GNOME/AMD fullscreen regression | NOT RUN | Headless sway software GL cannot reproduce GNOME/AMD Vulkan | + +The first Wayland launch exposed a resize/device-resource bug: ordinary quads +panicked while binding a backdrop placeholder after surface invalidation. The +renderer now retains the placeholder across resizes; all eight listed runtime +programs remained alive after the fix. + +This evidence does **not** justify “validated on hardware”. The remaining +hardware, X11, video lifecycle/alpha, blur visual/performance, and GNOME/AMD +checks still require a Linux desktop with an AMD Vulkan render node. diff --git a/docs/windows-runtime-validation.md b/docs/windows-runtime-validation.md new file mode 100644 index 0000000..4defc01 --- /dev/null +++ b/docs/windows-runtime-validation.md @@ -0,0 +1,29 @@ +# Windows runtime validation (WI-1439) + +Host: `desktop-tlk1gff` (Windows, GPU, console session 1). Build: `main` 2b77306, +`cargo build --workspace --all-targets` passed. Each example was launched in the +interactive session through a one-off scheduled task (`/IT /RU`), then the task +was deleted. Nothing persistent was left behind. + +| Example | Launches in session 1 | Alive after 10 s | Window title | Pixel check | +|---|---|---|---|---| +| hello_world | yes | yes | Hello FluentGUI | inconclusive | +| demo_app | yes | yes | FluentGUI Demo - Project Tracker | inconclusive | +| gallery | yes | yes | FluentGUI Gallery | inconclusive | +| video_frame (`vendor/zed/crates/gpui`) | yes | yes | (none) | inconclusive | + +Over plain SSH the same binaries panic with `cannot open windows while headless` +(expected: no desktop in the SSH session). + +## Not verified +- Screenshots: every capture (full screen `CopyFromScreen`, window-rect crop and + `PrintWindow`) came back uniformly white, even with a correct window rect. + The RDP console or DirectX flip-model presentation is likely not capturable + this way, so rendered output is unconfirmed. +- Window chrome (move/resize/zoom/minimise), VideoFrame items 2 and 7, and the + backdrop-blur fallback need a human at the console or a capture path that + reads the swap chain (for example DXGI Desktop Duplication). + +## Note +`cargo run -p gpui --example video_frame` does not resolve from the workspace +root; the example lives in `vendor/zed/crates/gpui` and builds from there. diff --git a/examples/gallery/src/families.rs b/examples/gallery/src/families.rs index 66e9d01..4ede217 100644 --- a/examples/gallery/src/families.rs +++ b/examples/gallery/src/families.rs @@ -6,15 +6,35 @@ //! family can be checked in every variant without leaving the page. use fluent_core::gpui::{ - div, prelude::*, AnyElement, AnyView, Context, Entity, IntoElement, Render, Window, + div, prelude::*, AnyElement, AnyView, Context, Entity, FocusHandle, IntoElement, Render, Window, }; use fluent_core::{LayoutDirection, Theme, ThemeProvider as _}; +use fluent_layout::{ + drag_source, drop_target, DragOperation, DropPolicy, GroupedVirtualList, VirtualListGroup, +}; use fluent_primitives::{ Button, ButtonAppearance, ButtonIconPosition, ButtonShape, ButtonSize, Checkbox, CheckboxState, Chip, Divider, Expander, InputCapture, Label, LabelSize, Link, LinkAppearance, RadioGroup, Switch, TerminalView, ToggleButton, VideoSurface, }; +pub struct GroupedListPage; + +impl Render for GroupedListPage { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let groups = vec![ + VirtualListGroup::new("inbox", "Inbox", 8), + VirtualListGroup::new("archive", "Archive", 6).expanded(false), + VirtualListGroup::new("large", "Large collection", 1000), + ]; + GroupedVirtualList::new("gallery-grouped-list", groups, |group, row, _, _| { + Label::new(format!("Group {group} · item {row}")).into_any_element() + }) + .row_height(32.0) + .into_any_element() + } +} + pub struct VideoPage { pub surface: Entity, } @@ -90,6 +110,59 @@ impl Render for ExpanderPage { } } +/// Small contract demo: the source and target are intentionally generic so +/// this page exercises the same API an application uses for pane actions. +pub struct DragDropPage { + focus_handle: FocusHandle, +} + +impl DragDropPage { + pub fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle().tab_stop(true), + } + } +} + +struct DragGhost; + +impl Render for DragGhost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + Label::new("Dragging item").into_any_element() + } +} + +impl Render for DragDropPage { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let source = drag_source( + div().p_3().bg(cx.theme().colors.surface_dim), + "gallery-drag-source", + 1_u32, + |_value, _offset, _window, cx| cx.new(|_| DragGhost), + ); + let target = drop_target( + div() + .p_3() + .border_1() + .border_color(cx.theme().colors.stroke_neutral_subtle), + "gallery-drop-target", + self.focus_handle.clone(), + cx.theme().colors.focus_ring, + DropPolicy::new(|value: &u32| *value == 1), + |_value, operation: DragOperation, _window, _cx| { + let _ = operation; + true + }, + Some(1_u32), + ); + div() + .flex() + .gap_3() + .child(source.child(Label::new("Drag this item"))) + .child(target.child(Label::new("Drop here (Enter/Space)"))) + } +} + /// A Fluent 2 component family, one gallery route each. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Family { diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index e148f55..e00e010 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -63,10 +63,12 @@ fn main() { let input_page = cx.new(|_| families::InputPage { capture }); let expander_page = cx.new(|_| families::ExpanderPage); + let drag_drop_page = cx.new(families::DragDropPage::new); + let grouped_list_page = cx.new(|_| families::GroupedListPage); let family_host = cx.new(|_| { FamilyHost::new(DemoViews { status: Vec::new(), - data: Vec::new(), + data: vec![drag_drop_page.into(), grouped_list_page.into()], date_input: Vec::new(), media: vec![video_page.clone().into(), terminal_page.clone().into()], theme: vec![input_page.clone().into(), expander_page.clone().into()], diff --git a/outstanding.md b/outstanding.md index f6c9cd3..f39e2ec 100644 --- a/outstanding.md +++ b/outstanding.md @@ -32,13 +32,13 @@ The data contract is tightly packed RGBA8 matching the allocation size. Ids are | Platform/backend | Status | Notes | |------------------|--------|-------| -| Linux / wgpu (`gpui_wgpu`) | Applied (WI-1348), not runtime-validated | Standalone `Rgba8Unorm` texture per id; `video_frame` pass | +| Linux / wgpu (`gpui_wgpu`) | Basic Wayland software-renderer validation; hardware validation outstanding | Generated video example rendered under headless sway/software GL (WI-1438); alpha, lifecycle/leak, X11, and AMD Vulkan remain unverified | | Windows / DirectX (`gpui_windows`) | Applied (WI-1434), **unverified on Windows** | Checked with `cargo check`/`clippy` for the Windows GNU target on Linux only; HLSL not compiled, not run | | GPUI test platform | Unsupported by design | Test platform returns unsupported for video texture allocation | ### Validation Example -`vendor/zed/crates/gpui/examples/video_frame.rs` (patch `video-frames`). It has not been run on a GPU in this repository's records. +`vendor/zed/crates/gpui/examples/video_frame.rs` (patch `video-frames`). It rendered under headless Wayland/software GL in WI-1438; it has not been run on a hardware GPU in this repository's records. ### Remaining VideoFrame Work @@ -52,7 +52,7 @@ The data contract is tightly packed RGBA8 matching the allocation size. Ids are ## Backdrop Blur Backend Support -Status: **not yet re-applied (WI-1349)**. `gpui::backdrop_blur(radius, tint)` as a `Background` variant is not present in the vendored `gpui`. The earlier implementation is not carried into `gpui_wgpu` or `gpui_windows`. +Status: **re-applied, runtime validation incomplete (WI-1349)**. `gpui::backdrop_blur(radius, tint)` is present in the vendored `gpui` and the wgpu renderer. WI-1438's headless demo run did not exercise a real blur surface, so overlapping-surface visuals and hardware performance remain unverified. The earlier design, to be re-applied: @@ -65,17 +65,16 @@ The earlier design, to be re-applied: | Platform/backend | Status | Notes | |------------------|--------|-------| -| Linux / wgpu (`gpui_wgpu`) | Not yet re-applied (WI-1349) | Main target; the renderer is rewritten on `gpui_wgpu` | -| Windows / DirectX (`gpui_windows`) | Not yet re-applied (WI-1349) | Needs the Windows fallback or equivalent blur | +| Linux / wgpu (`gpui_wgpu`) | Applied, not visually validated on hardware | Headless Wayland smoke passed, but the demo used only the blur-layer colour token | +| Windows / DirectX (`gpui_windows`) | Applied fallback, not runtime-validated | Requires a Windows runtime lane | | Web | Not implemented | Web backend itself is future work | ### Remaining Backdrop Blur Work -1. Re-apply the `Background` API and rewrite the renderer on `gpui_wgpu` (WI-1349). -2. Implement the Windows fallback (WI-1349). -3. Validate visual output on Linux with overlapping translucent surfaces. -4. Measure performance cost with multiple blur surfaces in a real app. -5. Document fallback guidance for FluentGUI consumers before advertising blur as cross-platform. +1. Validate visual output on Linux with overlapping translucent surfaces. +2. Measure performance cost with multiple blur surfaces in a real app. +3. Validate the Windows fallback on Windows hardware. +4. Document fallback guidance for FluentGUI consumers before advertising blur as cross-platform. --- diff --git a/screenshots/dark/app-dot.png b/screenshots/dark/app-dot.png index fdb54a7..dae4fe9 100644 Binary files a/screenshots/dark/app-dot.png and b/screenshots/dark/app-dot.png differ diff --git a/screenshots/dark/avatar.png b/screenshots/dark/avatar.png index dffd2eb..b933d60 100644 Binary files a/screenshots/dark/avatar.png and b/screenshots/dark/avatar.png differ diff --git a/screenshots/dark/badge.png b/screenshots/dark/badge.png index 11d081a..cbefc98 100644 Binary files a/screenshots/dark/badge.png and b/screenshots/dark/badge.png differ diff --git a/screenshots/dark/button.png b/screenshots/dark/button.png index 3fb647b..96b284d 100644 Binary files a/screenshots/dark/button.png and b/screenshots/dark/button.png differ diff --git a/screenshots/dark/card.png b/screenshots/dark/card.png index 953d84b..fd78d2b 100644 Binary files a/screenshots/dark/card.png and b/screenshots/dark/card.png differ diff --git a/screenshots/dark/checkbox.png b/screenshots/dark/checkbox.png index 6e272e1..ef91eb3 100644 Binary files a/screenshots/dark/checkbox.png and b/screenshots/dark/checkbox.png differ diff --git a/screenshots/dark/chip.png b/screenshots/dark/chip.png index 2417e0e..b22367a 100644 Binary files a/screenshots/dark/chip.png and b/screenshots/dark/chip.png differ diff --git a/screenshots/dark/combobox.png b/screenshots/dark/combobox.png index 6764b62..bf7b900 100644 Binary files a/screenshots/dark/combobox.png and b/screenshots/dark/combobox.png differ diff --git a/screenshots/dark/connection-badge.png b/screenshots/dark/connection-badge.png index 0543d11..de65238 100644 Binary files a/screenshots/dark/connection-badge.png and b/screenshots/dark/connection-badge.png differ diff --git a/screenshots/dark/divider.png b/screenshots/dark/divider.png index 05ff798..df5b8cd 100644 Binary files a/screenshots/dark/divider.png and b/screenshots/dark/divider.png differ diff --git a/screenshots/dark/dropdown.png b/screenshots/dark/dropdown.png index 4519ee8..8934efb 100644 Binary files a/screenshots/dark/dropdown.png and b/screenshots/dark/dropdown.png differ diff --git a/screenshots/dark/field.png b/screenshots/dark/field.png index 38d6600..f4583ca 100644 Binary files a/screenshots/dark/field.png and b/screenshots/dark/field.png differ diff --git a/screenshots/dark/icon-button.png b/screenshots/dark/icon-button.png index 9518de4..910a42d 100644 Binary files a/screenshots/dark/icon-button.png and b/screenshots/dark/icon-button.png differ diff --git a/screenshots/dark/label.png b/screenshots/dark/label.png index 7e6233e..76f504a 100644 Binary files a/screenshots/dark/label.png and b/screenshots/dark/label.png differ diff --git a/screenshots/dark/progress-bar.png b/screenshots/dark/progress-bar.png index 13ad945..fb3820b 100644 Binary files a/screenshots/dark/progress-bar.png and b/screenshots/dark/progress-bar.png differ diff --git a/screenshots/dark/radio-group.png b/screenshots/dark/radio-group.png index 023dc24..11f511a 100644 Binary files a/screenshots/dark/radio-group.png and b/screenshots/dark/radio-group.png differ diff --git a/screenshots/dark/searchbox.png b/screenshots/dark/searchbox.png index f2fc061..14bb59e 100644 Binary files a/screenshots/dark/searchbox.png and b/screenshots/dark/searchbox.png differ diff --git a/screenshots/dark/section-header.png b/screenshots/dark/section-header.png index 085f9d3..e8844b5 100644 Binary files a/screenshots/dark/section-header.png and b/screenshots/dark/section-header.png differ diff --git a/screenshots/dark/skeleton.png b/screenshots/dark/skeleton.png index bdd5e04..40c951a 100644 Binary files a/screenshots/dark/skeleton.png and b/screenshots/dark/skeleton.png differ diff --git a/screenshots/dark/spinner.png b/screenshots/dark/spinner.png index 4aed20c..19efed8 100644 Binary files a/screenshots/dark/spinner.png and b/screenshots/dark/spinner.png differ diff --git a/screenshots/dark/switch.png b/screenshots/dark/switch.png index 339cf1e..706d050 100644 Binary files a/screenshots/dark/switch.png and b/screenshots/dark/switch.png differ diff --git a/screenshots/dark/text-input.png b/screenshots/dark/text-input.png index 9289371..3c10e2f 100644 Binary files a/screenshots/dark/text-input.png and b/screenshots/dark/text-input.png differ diff --git a/screenshots/dark/textarea.png b/screenshots/dark/textarea.png index a196c86..916baee 100644 Binary files a/screenshots/dark/textarea.png and b/screenshots/dark/textarea.png differ diff --git a/screenshots/dark/toggle-button.png b/screenshots/dark/toggle-button.png index fd60318..d432a0f 100644 Binary files a/screenshots/dark/toggle-button.png and b/screenshots/dark/toggle-button.png differ diff --git a/screenshots/light/app-dot.png b/screenshots/light/app-dot.png index e20f808..1c0c283 100644 Binary files a/screenshots/light/app-dot.png and b/screenshots/light/app-dot.png differ diff --git a/screenshots/light/avatar.png b/screenshots/light/avatar.png index e222184..a190553 100644 Binary files a/screenshots/light/avatar.png and b/screenshots/light/avatar.png differ diff --git a/screenshots/light/badge.png b/screenshots/light/badge.png index 05cc5cb..c7e2e85 100644 Binary files a/screenshots/light/badge.png and b/screenshots/light/badge.png differ diff --git a/screenshots/light/button.png b/screenshots/light/button.png index d75481b..0824f76 100644 Binary files a/screenshots/light/button.png and b/screenshots/light/button.png differ diff --git a/screenshots/light/card.png b/screenshots/light/card.png index 6d67f9c..bf9be4a 100644 Binary files a/screenshots/light/card.png and b/screenshots/light/card.png differ diff --git a/screenshots/light/checkbox.png b/screenshots/light/checkbox.png index 3d68164..bb70619 100644 Binary files a/screenshots/light/checkbox.png and b/screenshots/light/checkbox.png differ diff --git a/screenshots/light/chip.png b/screenshots/light/chip.png index e4e0f00..eac81c6 100644 Binary files a/screenshots/light/chip.png and b/screenshots/light/chip.png differ diff --git a/screenshots/light/combobox.png b/screenshots/light/combobox.png index 7c45f78..e663158 100644 Binary files a/screenshots/light/combobox.png and b/screenshots/light/combobox.png differ diff --git a/screenshots/light/connection-badge.png b/screenshots/light/connection-badge.png index 194c9f5..7bd0f63 100644 Binary files a/screenshots/light/connection-badge.png and b/screenshots/light/connection-badge.png differ diff --git a/screenshots/light/divider.png b/screenshots/light/divider.png index 4691bf3..f1b87ef 100644 Binary files a/screenshots/light/divider.png and b/screenshots/light/divider.png differ diff --git a/screenshots/light/dropdown.png b/screenshots/light/dropdown.png index c97fddb..a51eb7d 100644 Binary files a/screenshots/light/dropdown.png and b/screenshots/light/dropdown.png differ diff --git a/screenshots/light/field.png b/screenshots/light/field.png index 508ec5e..b5da294 100644 Binary files a/screenshots/light/field.png and b/screenshots/light/field.png differ diff --git a/screenshots/light/icon-button.png b/screenshots/light/icon-button.png index b6dfe79..5fb1397 100644 Binary files a/screenshots/light/icon-button.png and b/screenshots/light/icon-button.png differ diff --git a/screenshots/light/label.png b/screenshots/light/label.png index cab81a7..f36be3e 100644 Binary files a/screenshots/light/label.png and b/screenshots/light/label.png differ diff --git a/screenshots/light/progress-bar.png b/screenshots/light/progress-bar.png index 21b26a4..b6f18c9 100644 Binary files a/screenshots/light/progress-bar.png and b/screenshots/light/progress-bar.png differ diff --git a/screenshots/light/radio-group.png b/screenshots/light/radio-group.png index f14554b..cf044d9 100644 Binary files a/screenshots/light/radio-group.png and b/screenshots/light/radio-group.png differ diff --git a/screenshots/light/searchbox.png b/screenshots/light/searchbox.png index 80e48b4..d1e608c 100644 Binary files a/screenshots/light/searchbox.png and b/screenshots/light/searchbox.png differ diff --git a/screenshots/light/section-header.png b/screenshots/light/section-header.png index 68e8f4e..30d1300 100644 Binary files a/screenshots/light/section-header.png and b/screenshots/light/section-header.png differ diff --git a/screenshots/light/skeleton.png b/screenshots/light/skeleton.png index 2ad19db..b9a7c93 100644 Binary files a/screenshots/light/skeleton.png and b/screenshots/light/skeleton.png differ diff --git a/screenshots/light/spinner.png b/screenshots/light/spinner.png index cf2d0a8..efeb841 100644 Binary files a/screenshots/light/spinner.png and b/screenshots/light/spinner.png differ diff --git a/screenshots/light/switch.png b/screenshots/light/switch.png index 0d2a604..217e340 100644 Binary files a/screenshots/light/switch.png and b/screenshots/light/switch.png differ diff --git a/screenshots/light/text-input.png b/screenshots/light/text-input.png index 8000b59..2990a66 100644 Binary files a/screenshots/light/text-input.png and b/screenshots/light/text-input.png differ diff --git a/screenshots/light/textarea.png b/screenshots/light/textarea.png index 5ef770e..5f8ced2 100644 Binary files a/screenshots/light/textarea.png and b/screenshots/light/textarea.png differ diff --git a/screenshots/light/toggle-button.png b/screenshots/light/toggle-button.png index c48d591..303aed9 100644 Binary files a/screenshots/light/toggle-button.png and b/screenshots/light/toggle-button.png differ diff --git a/vendor/zed/crates/gpui_wgpu/src/wgpu_renderer.rs b/vendor/zed/crates/gpui_wgpu/src/wgpu_renderer.rs index 126920d..af33cf9 100644 --- a/vendor/zed/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/vendor/zed/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -229,9 +229,9 @@ impl WgpuResources { self.path_intermediate_view = None; self.path_msaa_texture = None; self.path_msaa_view = None; - self.backdrop_texture = None; - self.backdrop_view = None; - self.backdrop_bind_group = None; + // Keep the backdrop placeholder alive across surface resizes. Every + // quad pipeline binds it, including frames which contain no blur. A + // real capture is still resized lazily by `capture_backdrop`. } }