From f472fecff5f293d2d6e284ddb3b3e3eca24a163b Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Thu, 14 May 2026 15:31:20 -0700 Subject: [PATCH 01/22] Add .pi-lens/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f0ffc9a..b693949 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ target/ .DS_Store **/*.rs.bk bk_gpui/ +.pi-lens/ From b373e0bf1f7ec87ca1bbe2a5d8da3f3af227f04c Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Thu, 14 May 2026 16:25:44 -0700 Subject: [PATCH 02/22] feat(gpui)!: sync upstream GPUI changes BREAKING CHANGE: CursorStyle::None and cursor_none() were removed; use CursorHideMode/platform cursor hide APIs instead. --- crates/gpui/Cargo.toml | 38 +- crates/gpui/src/app.rs | 39 + crates/gpui/src/elements/list.rs | 782 ++++++++++++++++-- crates/gpui/src/elements/uniform_list.rs | 14 +- crates/gpui/src/executor.rs | 151 +--- crates/gpui/src/platform.rs | 21 +- crates/gpui/src/platform/test/platform.rs | 6 + crates/gpui/src/platform/visual_test.rs | 8 + crates/gpui/src/prelude.rs | 6 +- crates/gpui/src/window.rs | 100 ++- crates/gpui_linux/src/linux/platform.rs | 18 +- crates/gpui_linux/src/linux/wayland.rs | 6 - crates/gpui_linux/src/linux/wayland/client.rs | 140 +++- .../gpui_linux/src/linux/wayland/display.rs | 2 +- crates/gpui_linux/src/linux/x11/client.rs | 151 +++- crates/gpui_linux/src/linux/x11/display.rs | 2 +- crates/gpui_linux/src/linux/x11/window.rs | 4 +- crates/gpui_macos/src/display.rs | 2 +- crates/gpui_macos/src/platform.rs | 30 +- crates/gpui_macos/src/window.rs | 20 + crates/gpui_macros/src/styles.rs | 7 - crates/gpui_web/src/platform.rs | 95 ++- crates/gpui_windows/src/display.rs | 106 +-- crates/gpui_windows/src/events.rs | 36 +- crates/gpui_windows/src/platform.rs | 30 + crates/gpui_windows/src/util.rs | 3 +- crates/gpui_windows/src/window.rs | 17 +- 27 files changed, 1387 insertions(+), 447 deletions(-) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 306f022..c41b88c 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -19,24 +19,18 @@ workspace = true [features] default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ - "leak-detection", - "collections/test-support", - "util/test-support", - "http_client/test-support", - "wayland", - "x11", + "leak-detection", + "collections/test-support", + "util/test-support", + "http_client/test-support", + "wayland", + "x11", ] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] -wayland = [ - "bitflags", -] -x11 = [ - "scap?/x11", -] -screen-capture = [ - "scap", -] +wayland = ["bitflags"] +x11 = ["scap?/x11"] +screen-capture = ["scap"] windows-manifest = [] [lib] @@ -77,10 +71,10 @@ refineable.workspace = true regex.workspace = true scheduler.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ - "text", - "system-fonts", - "memmap-fonts", - "raster-images", + "text", + "system-fonts", + "memmap-fonts", + "raster-images", ] } usvg = { version = "0.45.0", default-features = false } ttf-parser = "0.25" @@ -132,7 +126,6 @@ pathfinder_geometry = "0.5" scap = { workspace = true, optional = true } - [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.61", features = ["Win32_Foundation"] } @@ -141,7 +134,7 @@ windows = { version = "0.61", features = ["Win32_Foundation"] } backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true -gpui_platform.workspace = true +gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] } http_client = { workspace = true, features = ["test-support"] } lyon = { version = "1.0", features = ["extra"] } pretty_assertions.workspace = true @@ -160,7 +153,6 @@ wasm-bindgen = { workspace = true } gpui_web.workspace = true - [target.'cfg(target_os = "windows")'.build-dependencies] embed-resource = "3.0" @@ -170,8 +162,6 @@ cbindgen = { version = "0.28.0", default-features = false } naga.workspace = true - - [[example]] name = "hello_world" path = "examples/hello_world.rs" diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 5f9c9e2..a882507 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -247,6 +247,22 @@ pub enum QuitMode { Explicit, } +/// Controls when GPUI hides the mouse cursor in response to keyboard input. +/// +/// Restoration on mouse motion is handled by the platform layer; this enum +/// only describes the policy for *triggering* a hide. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum CursorHideMode { + /// Never hide the cursor automatically. + Never, + /// Hide on character-producing key presses (typing). + OnTyping, + /// Hide on character-producing key presses, *and* when a key binding + /// resolves to an action that consumes the keystroke. + #[default] + OnTypingAndAction, +} + #[doc(hidden)] #[derive(Clone, PartialEq, Eq)] pub struct SystemWindowTab { @@ -639,6 +655,7 @@ pub struct App { pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, + pub(crate) cursor_hide_mode: CursorHideMode, flushing_effects: bool, pending_updates: usize, quit_mode: QuitMode, @@ -727,6 +744,7 @@ impl App { inspector_element_registry: InspectorElementRegistry::default(), quit_mode: QuitMode::default(), quitting: false, + cursor_hide_mode: CursorHideMode::default(), #[cfg(any(test, feature = "test-support", debug_assertions))] name: None, @@ -836,6 +854,27 @@ impl App { self.platform.quit(); } + /// Returns the current policy for hiding the cursor in response to + /// keyboard input. + pub fn cursor_hide_mode(&self) -> CursorHideMode { + self.cursor_hide_mode + } + + /// Sets the policy controlling when GPUI hides the cursor in response + /// to keyboard input. + pub fn set_cursor_hide_mode(&mut self, mode: CursorHideMode) { + self.cursor_hide_mode = mode; + } + + /// Returns whether the cursor is currently visible according to the + /// platform. This will report `false` after a keyboard input has hidden + /// the cursor and the user has not yet moved the mouse to restore it. + /// + /// See [`App::set_cursor_hide_mode`]. + pub fn is_cursor_visible(&self) -> bool { + self.platform.is_cursor_visible() + } + /// Schedules all windows in the application to be redrawn. This can be called /// multiple times in an update cycle and still result in a single redraw. pub fn refresh_windows(&mut self) { diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 91b4dd9..95dd63b 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -71,12 +71,27 @@ struct StateInner { scroll_handler: Option>, scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, - pending_scroll: Option, + pending_scroll: Option, follow_state: FollowState, } +/// Deferred scroll adjustment applied after the scroll-top item has been remeasured. +/// +/// An absolute pending scroll preserves the same pixel offset into the item, which keeps +/// visible text stable while content is appended to or removed from that item. A +/// proportional pending scroll preserves the same fractional position within the item, +/// which is useful when the whole list is being resized and each item scales similarly. +#[derive(Clone)] +enum PendingScroll { + /// Preserve the same pixel offset into the item after it is remeasured. + Absolute { item_ix: usize, offset: Pixels }, + /// Preserve the same fractional offset into the item after it is remeasured. + Proportional(PendingScrollFraction), +} + /// Keeps track of a fractional scroll position within an item for restoration /// after remeasurement. +#[derive(Clone)] struct PendingScrollFraction { /// The index of the item to scroll within. item_ix: usize, @@ -84,6 +99,15 @@ struct PendingScrollFraction { fraction: f32, } +/// Determines how remeasurement preserves the scroll position when the scroll-top item +/// changes height. +enum ScrollAnchor { + /// Preserve the same pixel offset into the scroll-top item. + Absolute, + /// Preserve the same fractional position within the scroll-top item. + Proportional, +} + /// Controls whether the list automatically follows new content at the end. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum FollowMode { @@ -220,6 +244,7 @@ pub struct ListPrepaintState { #[derive(Clone)] enum ListItem { Unmeasured { + size_hint: Option>, focus_handle: Option, }, Measured { @@ -237,9 +262,16 @@ impl ListItem { } } + fn size_hint(&self) -> Option> { + match self { + ListItem::Measured { size, .. } => Some(*size), + ListItem::Unmeasured { size_hint, .. } => *size_hint, + } + } + fn focus_handle(&self) -> Option { match self { - ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => { focus_handle.clone() } } @@ -247,7 +279,7 @@ impl ListItem { fn contains_focused(&self, window: &Window, cx: &App) -> bool { match self { - ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => { focus_handle .as_ref() .is_some_and(|handle| handle.contains_focused(window, cx)) @@ -263,6 +295,7 @@ struct ListItemSummary { unrendered_count: usize, height: Pixels, has_focus_handles: bool, + has_unknown_height: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] @@ -326,37 +359,79 @@ impl ListState { /// Use this when item heights may have changed (e.g., font size changes) /// but the number and identity of items remains the same. pub fn remeasure(&self) { - let state = &mut *self.0.borrow_mut(); - - let new_items = state.items.iter().map(|item| ListItem::Unmeasured { - focus_handle: item.focus_handle(), - }); + let count = self.item_count(); + self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional); + } - // If there's a `logical_scroll_top`, we need to keep track of it as a - // `PendingScrollFraction`, so we can later preserve that scroll - // position proportionally to the item, in case the item's height - // changes. - if let Some(scroll_top) = state.logical_scroll_top { - let mut cursor = state.items.cursor::(()); - cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + /// Mark items in `range` as needing remeasurement while preserving + /// the current scroll position. Unlike [`Self::splice`], this does + /// not change the number of items or blow away `logical_scroll_top`. + /// + /// Use this when an item's content has changed and its rendered + /// height may be different (e.g., streaming text, tool results + /// loading), but the item itself still exists at the same index. + pub fn remeasure_items(&self, range: Range) { + self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute); + } - if let Some(item) = cursor.item() { - if let Some(size) = item.size() { - let fraction = if size.height.0 > 0.0 { - (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0) - } else { - 0.0 - }; + fn remeasure_items_with_scroll_anchor(&self, range: Range, scroll_anchor: ScrollAnchor) { + let state = &mut *self.0.borrow_mut(); - state.pending_scroll = Some(PendingScrollFraction { + if let Some(scroll_top) = state.logical_scroll_top { + if range.contains(&scroll_top.item_ix) { + state.pending_scroll = match scroll_anchor { + ScrollAnchor::Absolute => Some(PendingScroll::Absolute { item_ix: scroll_top.item_ix, - fraction, - }); - } + offset: scroll_top.offset_in_item, + }), + ScrollAnchor::Proportional => { + // If the scroll-top item falls within the remeasured range, + // store a fractional offset so the layout can restore the + // proportional scroll position after the item is re-rendered + // at its new height. + let mut cursor = state.items.cursor::(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + + cursor + .item() + .and_then(|item| { + item.size().map(|size| { + let fraction = if size.height.0 > 0.0 { + (scroll_top.offset_in_item.0 / size.height.0) + .clamp(0.0, 1.0) + } else { + 0.0 + }; + + PendingScroll::Proportional(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction, + }) + }) + }) + .or_else(|| state.pending_scroll.clone()) + } + }; } } - state.items = SumTree::from_iter(new_items, ()); + // Rebuild the tree, replacing items in the range with + // Unmeasured copies that keep their focus handles and prior size hints. + let new_items = { + let mut cursor = state.items.cursor::(()); + let mut new_items = cursor.slice(&Count(range.start), Bias::Right); + let invalidated = cursor.slice(&Count(range.end), Bias::Right); + new_items.extend( + invalidated.iter().map(|item| ListItem::Unmeasured { + size_hint: item.size_hint(), + focus_handle: item.focus_handle(), + }), + (), + ); + new_items.append(cursor.suffix(), ()); + new_items + }; + state.items = new_items; state.measuring_behavior.reset(); } @@ -365,6 +440,25 @@ impl ListState { self.0.borrow().items.summary().count } + /// Whether the list is scrolled to the end, or `None` if the list is + /// not scrollable or the total content height is not yet known. + pub fn is_scrolled_to_end(&self) -> Option { + let state = self.0.borrow(); + let bounds = state.last_layout_bounds?; + let summary = state.items.summary(); + if summary.has_unknown_height { + return None; + } + let padding = state.last_padding.unwrap_or_default(); + let content_height = summary.height + padding.top + padding.bottom; + let scroll_max = (content_height - bounds.size.height).max(px(0.)); + if scroll_max <= px(0.) { + return None; + } + let scroll_top = state.scroll_top(&state.logical_scroll_top()); + Some(scroll_top >= scroll_max) + } + /// Inform the list state that the items in `old_range` have been replaced /// by `count` new items that must be recalculated. pub fn splice(&self, old_range: Range, count: usize) { @@ -390,7 +484,10 @@ impl ListState { new_items.extend( focus_handles.into_iter().map(|focus_handle| { spliced_count += 1; - ListItem::Unmeasured { focus_handle } + ListItem::Unmeasured { + size_hint: None, + focus_handle, + } }), (), ); @@ -588,6 +685,16 @@ impl ListState { self.0.borrow_mut().scrollbar_drag_start_height.take(); } + /// Returns `true` if the scrollbar is currently being dragged. + /// + /// This is set between [`scrollbar_drag_started`](Self::scrollbar_drag_started) + /// and [`scrollbar_drag_ended`](Self::scrollbar_drag_ended) calls. Useful for + /// consumers that need to distinguish scrollbar drags from wheel/trackpad scrolls, + /// e.g. to suppress auto-scroll behavior during manual positioning. + pub fn is_scrollbar_dragging(&self) -> bool { + self.0.borrow().scrollbar_drag_start_height.is_some() + } + /// Set the offset from the scrollbar pub fn set_offset_from_scrollbar(&self, point: Point) { self.0.borrow_mut().set_offset_from_scrollbar(point); @@ -600,7 +707,10 @@ impl ListState { point(Pixels::ZERO, state.max_scroll_offset()) } - /// Returns the current scroll offset adjusted for the scrollbar + /// Returns the current scroll offset adjusted for the scrollbar. + /// + /// The returned offset has a negative `y` component representing + /// how far the content has scrolled. pub fn scroll_px_offset_for_scrollbar(&self) -> Point { let state = &self.0.borrow(); @@ -613,11 +723,7 @@ impl ListState { let mut cursor = state.items.cursor::(()); let summary: ListItemSummary = cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right); - let content_height = state.items.summary().height; - let drag_offset = - // if dragging the scrollbar, we want to offset the point if the height changed - content_height - state.scrollbar_drag_start_height.unwrap_or(content_height); - let offset = summary.height + logical_scroll_top.offset_in_item - drag_offset; + let offset = summary.height + logical_scroll_top.offset_in_item; Point::new(px(0.), -offset) } @@ -829,10 +935,23 @@ impl StateInner { // maintained after re-measuring. if ix == 0 { if let Some(pending_scroll) = self.pending_scroll.take() { - if pending_scroll.item_ix == scroll_top.item_ix { - scroll_top.offset_in_item = - Pixels(pending_scroll.fraction * element_size.height.0); - self.logical_scroll_top = Some(scroll_top); + match pending_scroll { + PendingScroll::Absolute { item_ix, offset } + if item_ix == scroll_top.item_ix => + { + scroll_top.offset_in_item = offset.min(element_size.height); + self.logical_scroll_top = Some(scroll_top); + } + PendingScroll::Proportional(pending_scroll) + if pending_scroll.item_ix == scroll_top.item_ix => + { + // Ensuring proportional scroll position is + // maintained after re-measuring. + scroll_top.offset_in_item = + Pixels(pending_scroll.fraction * element_size.height.0); + self.logical_scroll_top = Some(scroll_top); + } + _ => {} } } } @@ -1078,12 +1197,30 @@ impl StateInner { let height = bounds.size.height; let padding = self.last_padding.unwrap_or_default(); - let content_height = self.items.summary().height; + // Scrollbar drag positions are computed from the content height + // captured at drag start, so map them back using the same height. + let content_height = self + .scrollbar_drag_start_height + .unwrap_or_else(|| self.items.summary().height); let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.)); - let drag_offset = - // if dragging the scrollbar, we want to offset the point if the height changed - content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); - let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + let new_scroll_top = (-point.y).max(px(0.)).min(scroll_max); + + // If content grew during the drag, the frozen bottom is below the + // live bottom. Treat dragging to the frozen end as resuming tail follow. + let dragged_to_end = + scroll_max > px(0.) && new_scroll_top >= (scroll_max - px(1.0)).max(px(0.)); + if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) { + self.follow_state = FollowState::Tail { is_following: true }; + if self.alignment == ListAlignment::Bottom { + self.logical_scroll_top = None; + } else { + self.logical_scroll_top = Some(ListOffset { + item_ix: self.items.summary().count, + offset_in_item: px(0.), + }); + } + return; + } self.follow_state.stop_following(); @@ -1233,6 +1370,7 @@ impl Element for List { { let new_items = SumTree::from_iter( state.items.iter().map(|item| ListItem::Unmeasured { + size_hint: None, focus_handle: item.focus_handle(), }), (), @@ -1319,12 +1457,20 @@ impl sum_tree::Item for ListItem { fn summary(&self, _: ()) -> Self::Summary { match self { - ListItem::Unmeasured { focus_handle } => ListItemSummary { + ListItem::Unmeasured { + size_hint, + focus_handle, + } => ListItemSummary { count: 1, rendered_count: 0, unrendered_count: 1, - height: px(0.), + height: if let Some(size) = size_hint { + size.height + } else { + px(0.) + }, has_focus_handles: focus_handle.is_some(), + has_unknown_height: size_hint.is_none(), }, ListItem::Measured { size, focus_handle, .. @@ -1334,6 +1480,7 @@ impl sum_tree::Item for ListItem { unrendered_count: 0, height: size.height, has_focus_handles: focus_handle.is_some(), + has_unknown_height: false, }, } } @@ -1350,6 +1497,7 @@ impl sum_tree::ContextLessSummary for ListItemSummary { self.unrendered_count += summary.unrendered_count; self.height += summary.height; self.has_focus_handles |= summary.has_focus_handles; + self.has_unknown_height |= summary.has_unknown_height; } } @@ -1393,8 +1541,8 @@ mod test { use std::rc::Rc; use crate::{ - self as gpui, AppContext, Context, Element, IntoElement, ListState, Render, Styled, - TestAppContext, Window, div, list, point, px, size, + self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render, + Styled, TestAppContext, Window, div, list, point, px, size, }; #[gpui::test] @@ -1580,4 +1728,544 @@ mod test { assert_eq!(offset.item_ix, 2); assert_eq!(offset.offset_in_item, px(20.)); } + + #[gpui::test] + fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 5 { height } else { 100 }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + item_height.set(200); + state.remeasure_items(5..6); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(40.)); + } + + #[gpui::test] + fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items, each 50px tall → 500px total content, 200px viewport. + // With follow-tail on, the list should always show the bottom. + let item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |_, _, _| { + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + + // Items grow. 10 × 80 = 800px total. + item_height.set(80); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 7); + assert_eq!(offset.offset_in_item, px(40.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + assert!(state.is_following_tail()); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(100.))), + ..Default::default() + }); + + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the user scrolls toward the start" + ); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Negative-offset scrollbar contract (upstream 1c61cc3fc2). + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the scrollbar manually repositions the list" + ); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let last_item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView { + state: ListState, + last_item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let last_item_height = self.last_item_height.clone(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 9 { + last_item_height.get() + } else { + 50 + }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state.clone(), + last_item_height: last_item_height.clone(), + }) + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.scrollbar_drag_started(); + + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar(); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + + // Content grows during the drag — frozen height must keep the scrollbar + // thumb position stable. + last_item_height.set(550); + state.remeasure_items(9..10); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); + assert_eq!( + state.scroll_px_offset_for_scrollbar(), + scrollbar_offset_before_growth + ); + + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.scroll_to(gpui::ListOffset { + item_ix: 3, + offset_in_item: px(0.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(!state.is_following_tail()); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + } + + /// Bottom-aligned end-state contract: fork preserves + /// `logical_scroll_top = None` (per Batch C decision C1), and the public + /// `logical_scroll_top()` resolves that to `item_ix == item_count`. + /// Scrollbar offset math must still report the list as fully scrolled. + #[gpui::test] + fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + const ITEMS: usize = 10; + const ITEM_SIZE: f32 = 50.0; + + let state = ListState::new( + ITEMS, + crate::ListAlignment::Bottom, + px(ITEMS as f32 * ITEM_SIZE), + ); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(ITEM_SIZE)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + assert_eq!(state.logical_scroll_top().item_ix, ITEMS); + + let max_offset = state.max_offset_for_scrollbar(); + let scroll_offset = state.scroll_px_offset_for_scrollbar(); + + assert_eq!( + -scroll_offset.y, max_offset.y, + "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom", + -scroll_offset.y, max_offset.y, + ); + } + + /// Scroll-wheel away from bottom suspends follow_tail; scrolling back + /// re-engages on the next paint. + #[gpui::test] + fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(50.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))), + ..Default::default() + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + state.is_following_tail(), + "follow_tail should re-engage after scrolling back to the bottom" + ); + } + + /// Re-engagement uses fresh measurements: an unmeasured item temporarily + /// reducing summary.height must not fool the bottom-check. + #[gpui::test] + fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(20, crate::ListAlignment::Top, px(1000.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(200.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + state.remeasure_items(19..20); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + !state.is_following_tail(), + "follow_tail should not falsely re-engage due to an unmeasured item \ + reducing items.summary().height" + ); + } + + #[gpui::test] + fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + assert!(!state.is_following_tail()); + + state.set_offset_from_scrollbar(point(px(0.), px(-300.))); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + assert!( + state.is_following_tail(), + "follow_tail should re-engage after scrolling back to the bottom via the scrollbar" + ); + } + + /// When the user drags the scrollbar all the way to the bottom while + /// content grows mid-drag (so the frozen scroll height is less than the live + /// scroll height), `set_offset_from_scrollbar` re-engages follow_tail. + /// + /// Fork adaptation: bottom-aligned lists still store their end position as + /// `None`; top-aligned lists use the upstream `item_count` anchor. + #[gpui::test] + fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + state.scrollbar_drag_started(); + + state.splice(10..10, 10); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.set_offset_from_scrollbar(point(px(0.), px(-300.))); + state.scrollbar_drag_ended(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + assert!( + state.is_following_tail(), + "follow_tail should re-engage when the user drags the scrollbar to \ + the bottom of its track, even when content has grown during the drag \ + (so frozen_bottom < live_bottom)" + ); + } } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index a7486f0..c439c90 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -8,7 +8,7 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, - StyleRefinement, Styled, Window, point, size, + StyleRefinement, Styled, Window, point, px, size, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize}; @@ -236,6 +236,18 @@ impl UniformListScrollHandle { } } + /// Whether the list is scrolled to the end, or `None` if the list is + /// not scrollable. + pub fn is_scrolled_to_end(&self) -> Option { + let state = self.0.borrow(); + let max_offset = state.base_handle.max_offset(); + if max_offset.height <= px(0.) { + return None; + } + let offset = state.base_handle.offset(); + Some(-offset.y >= max_offset.height) + } + /// Scroll to the bottom of the list. pub fn scroll_to_bottom(&self) { self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom); diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 2f8a685..c58f460 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,7 +1,7 @@ use crate::{App, PlatformDispatcher, PlatformScheduler}; use futures::channel::mpsc; use futures::prelude::*; -use gpui_util::TryFutureExt; +use gpui_util::{TryFutureExt, TryFutureExtBacktrace}; use scheduler::Scheduler; use std::{ future::Future, @@ -13,7 +13,9 @@ use std::{ time::{Duration, Instant}, }; -pub use scheduler::{FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority}; +pub use scheduler::{ + FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority, Task, +}; /// A pointer to the executor that is currently running, /// for spawning background tasks. @@ -32,85 +34,36 @@ pub struct ForegroundExecutor { not_send: PhantomData>, } -/// Task is a primitive that allows work to happen in the background. -/// -/// It implements [`Future`] so you can `.await` on it. +/// Extension trait for `Task>` that adds `detach_and_log_err` with an `&App` context. /// -/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows -/// the task to continue running, but with no way to return a value. -#[must_use] -#[derive(Debug)] -pub struct Task(scheduler::Task); - -impl Task { - /// Creates a new task that will resolve with the value. - pub fn ready(val: T) -> Self { - Task(scheduler::Task::ready(val)) - } - - /// Returns true if the task has completed or was created with `Task::ready`. - pub fn is_ready(&self) -> bool { - self.0.is_ready() - } - - /// Detaching a task runs it to completion in the background. - pub fn detach(self) { - self.0.detach() - } - - /// Wraps a scheduler::Task. - pub fn from_scheduler(task: scheduler::Task) -> Self { - Task(task) - } - - /// Converts this task into a fallible task that returns `Option`. - /// - /// Unlike the standard `Task`, a [`FallibleTask`] will return `None` - /// if the task was cancelled. - /// - /// # Example - /// - /// ```ignore - /// // Background task that gracefully handles cancellation: - /// cx.background_spawn(async move { - /// let result = foreground_task.fallible().await; - /// if let Some(value) = result { - /// // Process the value - /// } - /// // If None, task was cancelled - just exit gracefully - /// }).detach(); - /// ``` - pub fn fallible(self) -> FallibleTask { - self.0.fallible() - } +/// This trait is automatically implemented for all `Task>` types. +pub trait TaskExt { + /// Run the task to completion in the background and log any errors that occur. + fn detach_and_log_err(self, cx: &App); + /// Like [`Self::detach_and_log_err`], but uses `{:?}` formatting on failure so `anyhow::Error` + /// values emit their full backtrace. Prefer `detach_and_log_err` unless a backtrace is wanted. + fn detach_and_log_err_with_backtrace(self, cx: &App); } -impl Task> +impl TaskExt for Task> where T: 'static, - E: 'static + std::fmt::Display, + E: 'static + std::fmt::Display + std::fmt::Debug, { - /// Run the task to completion in the background and log any errors that occur. #[track_caller] - pub fn detach_and_log_err(self, cx: &App) { + fn detach_and_log_err(self, cx: &App) { let location = core::panic::Location::caller(); cx.foreground_executor() .spawn(self.log_tracked_err(*location)) .detach(); } -} -impl std::future::Future for Task { - type Output = T; - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - // SAFETY: Task is a repr(transparent) wrapper around scheduler::Task, - // and we're just projecting the pin through to the inner task. - let inner = unsafe { self.map_unchecked_mut(|t| &mut t.0) }; - inner.poll(cx) + #[track_caller] + fn detach_and_log_err_with_backtrace(self, cx: &App) { + let location = *core::panic::Location::caller(); + cx.foreground_executor() + .spawn(self.log_tracked_err_with_backtrace(location)) + .detach(); } } @@ -163,64 +116,10 @@ impl BackgroundExecutor { R: Send + 'static, { if priority == Priority::RealtimeAudio { - Task::from_scheduler(self.inner.spawn_realtime(future)) + self.inner.spawn_realtime(future) } else { - Task::from_scheduler(self.inner.spawn_with_priority(priority, future)) - } - } - - /// Enqueues the given future to be run to completion on a background thread and blocking the current task on it. - /// - /// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to - /// completion before the current task is resumed, even if the current task is slated for cancellation. - pub async fn await_on_background(&self, future: impl Future + Send) -> R - where - R: Send, - { - use crate::RunnableMeta; - use parking_lot::{Condvar, Mutex}; - - struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for NotifyOnDrop<'_> { - fn drop(&mut self) { - *self.0.1.lock() = true; - self.0.0.notify_all(); - } + self.inner.spawn_with_priority(priority, future) } - - struct WaitOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for WaitOnDrop<'_> { - fn drop(&mut self) { - let mut done = self.0.1.lock(); - if !*done { - self.0.0.wait(&mut done); - } - } - } - - let dispatcher = self.dispatcher.clone(); - let location = core::panic::Location::caller(); - - let pair = &(Condvar::new(), Mutex::new(false)); - let _wait_guard = WaitOnDrop(pair); - - let (runnable, task) = unsafe { - async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn_unchecked( - move |_| async { - let _notify_guard = NotifyOnDrop(pair); - future.await - }, - move |runnable| { - dispatcher.dispatch(runnable, Priority::default()); - }, - ) - }; - runnable.schedule(); - task.await } /// Scoped lets you start a number of tasks and waits @@ -414,7 +313,7 @@ impl ForegroundExecutor { where R: 'static, { - Task::from_scheduler(self.inner.spawn(future.boxed_local())) + self.inner.spawn(future.boxed_local()) } /// Enqueues the given Task to run on the main thread with the given priority. @@ -428,7 +327,7 @@ impl ForegroundExecutor { R: 'static, { // Priority is ignored for foreground tasks - they run in order on the main thread - Task::from_scheduler(self.inner.spawn(future)) + self.inner.spawn(future) } /// Used by the test harness to run an async test in a synchronous fashion. diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 3502f95..142ce1d 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -204,6 +204,14 @@ pub trait Platform: 'static { fn path_for_auxiliary_executable(&self, name: &str) -> Result; fn set_cursor_style(&self, style: CursorStyle); + + /// Hides the mouse cursor until the user moves the mouse over one of + /// this application's windows. + fn hide_cursor_until_mouse_moves(&self); + + /// Returns whether the mouse cursor is currently visible. + fn is_cursor_visible(&self) -> bool; + fn should_auto_hide_scrollbars(&self) -> bool; fn read_from_clipboard(&self) -> Option; @@ -310,22 +318,22 @@ pub struct ScreenCaptureFrame(pub PlatformScreenCaptureFrame); /// An opaque identifier for a hardware display #[derive(PartialEq, Eq, Hash, Copy, Clone)] -pub struct DisplayId(pub(crate) u32); +pub struct DisplayId(pub(crate) u64); impl DisplayId { /// Create a new `DisplayId` from a raw platform display identifier. - pub fn new(id: u32) -> Self { + pub fn new(id: u64) -> Self { Self(id) } } -impl From for DisplayId { - fn from(id: u32) -> Self { +impl From for DisplayId { + fn from(id: u64) -> Self { Self(id) } } -impl From for u32 { +impl From for u64 { fn from(id: DisplayId) -> Self { id.0 } @@ -1752,9 +1760,6 @@ pub enum CursorStyle { /// A cursor indicating that the operation will result in a context menu /// corresponds to the CSS cursor value `context-menu` ContextualMenu, - - /// Hide the cursor - None, } /// A clipboard item that should be copied to the clipboard diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index a52c59c..e3388d2 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -404,6 +404,12 @@ impl Platform for TestPlatform { *self.active_cursor.lock() = style; } + fn hide_cursor_until_mouse_moves(&self) {} + + fn is_cursor_visible(&self) -> bool { + true + } + fn should_auto_hide_scrollbars(&self) -> bool { false } diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index 8b9bec7..3719a3e 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -202,6 +202,14 @@ impl Platform for VisualTestPlatform { self.platform.set_cursor_style(style) } + fn hide_cursor_until_mouse_moves(&self) { + self.platform.hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + self.platform.is_cursor_visible() + } + fn should_auto_hide_scrollbars(&self) -> bool { self.platform.should_auto_hide_scrollbars() } diff --git a/crates/gpui/src/prelude.rs b/crates/gpui/src/prelude.rs index 191d0a0..284464d 100644 --- a/crates/gpui/src/prelude.rs +++ b/crates/gpui/src/prelude.rs @@ -3,7 +3,7 @@ //! application to avoid having to import each trait individually. pub use crate::{ - AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, IntoElement, - ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, StyledImage, - VisualContext, util::FluentBuilder, + util::FluentBuilder, AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, + IntoElement, ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, + StyledImage, TaskExt as _, VisualContext, }; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f509ca3..60c1ce9 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5,21 +5,22 @@ use crate::util::{ResultExt, measure}; use crate::{ Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset, AsyncWindowContext, AvailableSpace, BackdropBlur, Background, BorderStyle, Bounds, BoxShadow, - Capslock, Context, Corners, CursorStyle, Decorations, DevicePixels, DispatchActionListener, - DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, - FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, Hsla, InputHandler, IsZero, - KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, KeystrokeEvent, LayoutId, - LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, - MouseMoveEvent, MouseUpEvent, OverlayInputMode, Path, Pixels, PlatformAtlas, PlatformDisplay, - PlatformInput, PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, Priority, - PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, - RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, - SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, - Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, SystemWindowTabController, - TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, TextStyleRefinement, - ThermalState, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, - WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions, - WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, transparent_black, + Capslock, Context, Corners, CursorHideMode, CursorStyle, Decorations, DevicePixels, + DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, + EntityId, EventEmitter, FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, + Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, + KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, + MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, OverlayInputMode, Path, Pixels, + PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PolychromeSprite, Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, + RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, + SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, + StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, + SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, + TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -1304,6 +1305,11 @@ impl Window { measure("frame duration", || { handle .update(&mut cx, |_, window, cx| { + if request_frame_options.force_render { + // Bypass cached view reuse so we don't replay stale + // atlas tile references after a GPU device recovery. + window.refresh(); + } let arena_clear_needed = window.draw(cx); window.present(); arena_clear_needed.clear(); @@ -2348,8 +2354,21 @@ impl Window { self.requested_autoscroll = None; // Restore the previously-used input handler. + // Place it back into a None slot (left by a previous .take()) so that + // cached paint_range indices in reuse_paint find the handler at the + // expected position. if let Some(input_handler) = self.platform_window.take_input_handler() { - self.rendered_frame.input_handlers.push(Some(input_handler)); + if let Some(slot) = self + .rendered_frame + .input_handlers + .iter_mut() + .rev() + .find(|h| h.is_none()) + { + *slot = Some(input_handler); + } else { + self.rendered_frame.input_handlers.push(Some(input_handler)); + } } if !cx.mode.skip_drawing() { self.draw_roots(cx); @@ -2358,9 +2377,18 @@ impl Window { self.next_frame.window_active = self.active.get(); // Register requested input handler with the platform window. - if let Some(input_handler) = self.next_frame.input_handlers.pop() { - self.platform_window - .set_input_handler(input_handler.unwrap()); + // Use .take() instead of .pop() to preserve Vec length, so that cached + // paint_range indices remain valid for reuse_paint on the next frame. + // Search backwards to find the last Some entry, since reuse_paint may + // have copied None slots from the previous frame. (Fixes #50456) + if let Some(input_handler) = self + .next_frame + .input_handlers + .iter_mut() + .rev() + .find_map(|h| h.take()) + { + self.platform_window.set_input_handler(input_handler); } self.layout_engine.as_mut().unwrap().clear(); @@ -4432,6 +4460,14 @@ impl Window { } else if let Some(key_down_event) = event.downcast_ref::() { self.pending_modifier.saw_keystroke = true; keystroke = Some(key_down_event.keystroke.clone()); + if key_down_event.keystroke.key_char.is_some() + && matches!( + cx.cursor_hide_mode, + CursorHideMode::OnTyping | CursorHideMode::OnTypingAndAction + ) + { + cx.platform.hide_cursor_until_mouse_moves(); + } } let Some(keystroke) = keystroke else { @@ -4697,6 +4733,22 @@ impl Window { node_id: DispatchNodeId, action: &dyn Action, cx: &mut App, + ) { + self.dispatch_action_on_node_inner(node_id, action, cx); + + if !cx.propagate_event + && cx.cursor_hide_mode == CursorHideMode::OnTypingAndAction + && self.last_input_was_keyboard() + { + cx.platform.hide_cursor_until_mouse_moves(); + } + } + + fn dispatch_action_on_node_inner( + &mut self, + node_id: DispatchNodeId, + action: &dyn Action, + cx: &mut App, ) { let dispatch_path = self.rendered_frame.dispatch_tree.dispatch_path(node_id); @@ -5694,7 +5746,7 @@ impl From> for ElementId { impl From<&'static str> for ElementId { fn from(name: &'static str) -> Self { - ElementId::Name(name.into()) + ElementId::Name(SharedString::new_static(name)) } } @@ -5706,13 +5758,13 @@ impl<'a> From<&'a FocusHandle> for ElementId { impl From<(&'static str, EntityId)> for ElementId { fn from((name, id): (&'static str, EntityId)) -> Self { - ElementId::NamedInteger(name.into(), id.as_u64()) + ElementId::NamedInteger(SharedString::new_static(name), id.as_u64()) } } impl From<(&'static str, usize)> for ElementId { fn from((name, id): (&'static str, usize)) -> Self { - ElementId::NamedInteger(name.into(), id as u64) + ElementId::NamedInteger(SharedString::new_static(name), id as u64) } } @@ -5724,7 +5776,7 @@ impl From<(SharedString, usize)> for ElementId { impl From<(&'static str, u64)> for ElementId { fn from((name, id): (&'static str, u64)) -> Self { - ElementId::NamedInteger(name.into(), id) + ElementId::NamedInteger(SharedString::new_static(name), id) } } @@ -5736,7 +5788,7 @@ impl From for ElementId { impl From<(&'static str, u32)> for ElementId { fn from((name, id): (&'static str, u32)) -> Self { - ElementId::NamedInteger(name.into(), id.into()) + ElementId::NamedInteger(SharedString::new_static(name), u64::from(id)) } } diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 4614be6..e536d99 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -123,6 +123,10 @@ pub(crate) trait LinuxClient { options: WindowParams, ) -> anyhow::Result>; fn set_cursor_style(&self, style: CursorStyle); + fn hide_cursor_until_mouse_moves(&self) {} + fn is_cursor_visible(&self) -> bool { + true + } fn open_uri(&self, uri: &str); fn reveal_path(&self, path: PathBuf); fn write_to_primary(&self, item: ClipboardItem); @@ -570,6 +574,14 @@ impl Platform for LinuxPlatform

{ self.inner.set_cursor_style(style) } + fn hide_cursor_until_mouse_moves(&self) { + self.inner.hide_cursor_until_mouse_moves() + } + + fn is_cursor_visible(&self) -> bool { + self.inner.is_cursor_visible() + } + fn should_auto_hide_scrollbars(&self) -> bool { self.inner.with_common(|common| common.auto_hide_scrollbars) } @@ -812,12 +824,6 @@ pub(super) fn cursor_style_to_icon_names(style: CursorStyle) -> &'static [&'stat CursorStyle::DragLink => &["alias"], CursorStyle::DragCopy => &["copy"], CursorStyle::ContextualMenu => &["context-menu"], - CursorStyle::None => { - #[cfg(debug_assertions)] - panic!("CursorStyle::None should be handled separately in the client"); - #[cfg(not(debug_assertions))] - &[DEFAULT_CURSOR_ICON_NAME] - } } } diff --git a/crates/gpui_linux/src/linux/wayland.rs b/crates/gpui_linux/src/linux/wayland.rs index aa1e797..3e90688 100644 --- a/crates/gpui_linux/src/linux/wayland.rs +++ b/crates/gpui_linux/src/linux/wayland.rs @@ -37,11 +37,5 @@ pub(super) fn to_shape(style: CursorStyle) -> Shape { CursorStyle::DragLink => Shape::Alias, CursorStyle::DragCopy => Shape::Copy, CursorStyle::ContextualMenu => Shape::ContextMenu, - CursorStyle::None => { - #[cfg(debug_assertions)] - panic!("CursorStyle::None should be handled separately in the client"); - #[cfg(not(debug_assertions))] - Shape::Default - } } } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 3b855be..8f7d6b5 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -248,6 +248,7 @@ pub(crate) struct WaylandClientState { keyboard_focused_window: Option, loop_handle: LoopHandle<'static, WaylandClientStatePtr>, cursor_style: Option, + cursor_hidden_window: Option, clipboard: Clipboard, data_offers: Vec>, primary_data_offer: Option>, @@ -402,6 +403,65 @@ impl WaylandClientStatePtr { { state.keyboard_focused_window = Some(window); } + if let Some(window) = state.cursor_hidden_window.take() + && !window.ptr_eq(&closed_window) + { + state.cursor_hidden_window = Some(window); + } + } +} + +impl WaylandClientState { + fn hide_cursor_until_mouse_moves(&mut self) { + if self.cursor_hidden_window.is_some() { + return; + } + let Some(focused_window) = self.mouse_focused_window.clone() else { + // No surface to apply the hidden cursor to. + return; + }; + let Some(wl_pointer) = self.wl_pointer.clone() else { + // Seat lost its pointer capability; nothing to hide. + return; + }; + let serial = self.serial_tracker.get(SerialKind::MouseEnter); + wl_pointer.set_cursor(serial, None, 0, 0); + self.cursor_hidden_window = Some(focused_window); + } + + fn restore_cursor_after_hide(&mut self) { + if self.cursor_hidden_window.take().is_none() { + return; + } + let Some(style) = self.cursor_style else { + return; + }; + let serial = self.serial_tracker.get(SerialKind::MouseEnter); + if let Some(cursor_shape_device) = &self.cursor_shape_device { + cursor_shape_device.set_shape(serial, to_shape(style)); + return; + } + let Some(focused_window) = self.mouse_focused_window.clone() else { + log::warn!( + "wayland: no focused surface to restore cursor style {:?} after hide; cursor may stay invisible", + style + ); + return; + }; + let Some(wl_pointer) = self.wl_pointer.clone() else { + log::warn!( + "wayland: no wl_pointer to restore cursor style {:?} after hide; cursor may stay invisible", + style + ); + return; + }; + let scale = focused_window.primary_output_scale(); + self.cursor.set_icon( + &wl_pointer, + serial, + cursor_style_to_icon_names(style), + scale, + ); } } @@ -642,6 +702,7 @@ impl WaylandClient { loop_handle: handle.clone(), enter_token: None, cursor_style: None, + cursor_hidden_window: None, clipboard: Clipboard::new(conn.clone(), handle.clone()), data_offers: Vec::new(), primary_data_offer: None, @@ -684,7 +745,7 @@ impl LinuxClient for WaylandClient { .outputs .iter() .find_map(|(object_id, output)| { - (object_id.protocol_id() == u32::from(id)).then(|| { + (object_id.protocol_id() as u64 == u64::from(id)).then(|| { Rc::new(WaylandDisplay { id: object_id.clone(), name: output.name.clone(), @@ -726,11 +787,11 @@ impl LinuxClient for WaylandClient { let parent = state.keyboard_focused_window.clone(); let target_output = params.display_id.and_then(|display_id| { - let target_protocol_id: u32 = display_id.into(); + let target_protocol_id: u64 = display_id.into(); state .wl_outputs .iter() - .find(|(id, _)| id.protocol_id() == target_protocol_id) + .find(|(id, _)| id.protocol_id() as u64 == target_protocol_id) .map(|(_, output)| output.clone()) }); @@ -759,35 +820,44 @@ impl LinuxClient for WaylandClient { .as_ref() .is_some_and(|w| !w.is_blocked())); - if need_update { - let serial = state.serial_tracker.get(SerialKind::MouseEnter); - state.cursor_style = Some(style); - - if let CursorStyle::None = style { - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); - wl_pointer.set_cursor(serial, None, 0, 0); - } else if let Some(cursor_shape_device) = &state.cursor_shape_device { - cursor_shape_device.set_shape(serial, to_shape(style)); - } else if let Some(focused_window) = &state.mouse_focused_window { - // cursor-shape-v1 isn't supported, set the cursor using a surface. - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); - let scale = focused_window.primary_output_scale(); - state.cursor.set_icon( - &wl_pointer, - serial, - cursor_style_to_icon_names(style), - scale, - ); - } + if !need_update { + return; + } + + state.cursor_style = Some(style); + + // Don't clobber the invisible cursor; restore reads back from `cursor_style`. + if state.cursor_hidden_window.is_some() { + return; + } + + let serial = state.serial_tracker.get(SerialKind::MouseEnter); + if let Some(cursor_shape_device) = &state.cursor_shape_device { + cursor_shape_device.set_shape(serial, to_shape(style)); + } else if let Some(focused_window) = &state.mouse_focused_window { + // cursor-shape-v1 isn't supported, set the cursor using a surface. + let wl_pointer = state + .wl_pointer + .clone() + .expect("window is focused by pointer"); + let scale = focused_window.primary_output_scale(); + state.cursor.set_icon( + &wl_pointer, + serial, + cursor_style_to_icon_names(style), + scale, + ); } } + fn hide_cursor_until_mouse_moves(&self) { + self.0.borrow_mut().hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + self.0.borrow().cursor_hidden_window.is_none() + } + fn open_uri(&self, uri: &str) { let mut state = self.0.borrow_mut(); if let (Some(activation), Some(window)) = ( @@ -1396,6 +1466,7 @@ impl Dispatch for WaylandClientStatePtr { state.enter_token.take(); // Prevent keyboard events from repeating after opening e.g. a file chooser and closing it quickly state.repeat.current_id += 1; + state.restore_cursor_after_hide(); if let Some(window) = keyboard_focused_window { if let Some(ref mut compose) = state.compose_state { @@ -1696,14 +1767,9 @@ impl Dispatch for WaylandClientStatePtr { if state.enter_token.is_some() { state.enter_token = None; } + state.restore_cursor_after_hide(); if let Some(style) = state.cursor_style { - if let CursorStyle::None = style { - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); - wl_pointer.set_cursor(serial, None, 0, 0); - } else if let Some(cursor_shape_device) = &state.cursor_shape_device { + if let Some(cursor_shape_device) = &state.cursor_shape_device { cursor_shape_device.set_shape(serial, to_shape(style)); } else { let scale = window.primary_output_scale(); @@ -1729,6 +1795,7 @@ impl Dispatch for WaylandClientStatePtr { state.mouse_focused_window = None; state.mouse_location = None; state.button_pressed = None; + state.cursor_hidden_window = None; drop(state); focused_window.handle_input(input); @@ -1744,6 +1811,7 @@ impl Dispatch for WaylandClientStatePtr { return; } state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); + state.restore_cursor_after_hide(); if let Some(window) = state.mouse_focused_window.clone() { if window.is_blocked() { diff --git a/crates/gpui_linux/src/linux/wayland/display.rs b/crates/gpui_linux/src/linux/wayland/display.rs index 874cae8..8fa9122 100644 --- a/crates/gpui_linux/src/linux/wayland/display.rs +++ b/crates/gpui_linux/src/linux/wayland/display.rs @@ -25,7 +25,7 @@ impl Hash for WaylandDisplay { impl PlatformDisplay for WaylandDisplay { fn id(&self) -> DisplayId { - DisplayId::new(self.id.protocol_id()) + DisplayId::new(self.id.protocol_id() as u64) } fn uuid(&self) -> anyhow::Result { diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 672dfaf..e2d794f 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -210,6 +210,8 @@ pub struct X11ClientState { pub(crate) cursor_handle: cursor::Handle, pub(crate) cursor_styles: HashMap, pub(crate) cursor_cache: HashMap>, + pub(crate) invisible_cursor_cache: Option, + pub(crate) cursor_hidden_window: Option, pointer_device_states: BTreeMap, pub(crate) pinch_scale: f32, @@ -248,6 +250,9 @@ impl X11ClientStatePtr { if state.keyboard_focused_window == Some(x_window) { state.keyboard_focused_window = None; } + if state.cursor_hidden_window == Some(x_window) { + state.cursor_hidden_window = None; + } state.cursor_styles.remove(&x_window); } @@ -531,6 +536,8 @@ impl X11Client { cursor_handle, cursor_styles: HashMap::default(), cursor_cache: HashMap::default(), + cursor_hidden_window: None, + invisible_cursor_cache: None, pointer_device_states, pinch_scale: 1.0, @@ -953,6 +960,7 @@ impl X11Client { compose_state.reset(); } state.pre_edit_text.take(); + state.restore_cursor_after_hide(); drop(state); self.reset_ime(); window.handle_ime_delete(); @@ -1269,6 +1277,7 @@ impl X11Client { Event::XinputMotion(event) => { let window = self.get_window(event.event)?; let mut state = self.0.borrow_mut(); + state.restore_cursor_after_hide(); if window.is_blocked() { // We want to set the cursor to the default arrow // when the window is blocked @@ -1331,6 +1340,7 @@ impl X11Client { window.set_hovered(true); let mut state = self.0.borrow_mut(); state.mouse_focused_window = Some(event.event); + state.restore_cursor_after_hide(); } Event::XinputLeave(event) if event.mode == xinput::NotifyMode::NORMAL => { let mut state = self.0.borrow_mut(); @@ -1544,7 +1554,7 @@ impl LinuxClient for X11Client { X11Display::new( &state.xcb_connection, state.scale_factor, - u32::from(id) as usize, + u64::from(id) as usize, ) .ok()?, )) @@ -1646,11 +1656,17 @@ impl LinuxClient for X11Client { return; } + state.cursor_styles.insert(focused_window, style); + + // Don't clobber the invisible cursor; restore reads back from `cursor_styles`. + if state.cursor_hidden_window == Some(focused_window) { + return; + } + let Some(cursor) = state.get_cursor_icon(style) else { return; }; - state.cursor_styles.insert(focused_window, style); check_reply( || "Failed to set cursor style", state.xcb_connection.change_window_attributes( @@ -1665,6 +1681,14 @@ impl LinuxClient for X11Client { state.xcb_connection.flush().log_err(); } + fn hide_cursor_until_mouse_moves(&self) { + self.0.borrow_mut().hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + self.0.borrow().cursor_hidden_window.is_none() + } + fn open_uri(&self, uri: &str) { #[cfg(any(feature = "wayland", feature = "x11"))] open_uri_internal( @@ -1965,42 +1989,34 @@ impl X11ClientState { return *cursor; } - let result; - match style { - CursorStyle::None => match create_invisible_cursor(&self.xcb_connection) { - Ok(loaded_cursor) => result = Ok(loaded_cursor), - Err(err) => result = Err(err.context("X11: error while creating invisible cursor")), - }, - _ => 'outer: { - let mut errors = String::new(); - let cursor_icon_names = cursor_style_to_icon_names(style); - for cursor_icon_name in cursor_icon_names { - match self - .cursor_handle - .load_cursor(&self.xcb_connection, cursor_icon_name) - { - Ok(loaded_cursor) => { - if loaded_cursor != x11rb::NONE { - result = Ok(loaded_cursor); - break 'outer; - } - } - Err(err) => { - errors.push_str(&err.to_string()); - errors.push('\n'); + let result = 'outer: { + let mut errors = String::new(); + let cursor_icon_names = cursor_style_to_icon_names(style); + for cursor_icon_name in cursor_icon_names { + match self + .cursor_handle + .load_cursor(&self.xcb_connection, cursor_icon_name) + { + Ok(loaded_cursor) => { + if loaded_cursor != x11rb::NONE { + break 'outer Ok(loaded_cursor); } } - } - if errors.is_empty() { - result = Err(anyhow!( - "errors while loading cursor icons {:?}:\n{}", - cursor_icon_names, - errors - )); - } else { - result = Err(anyhow!("did not find cursor icons {:?}", cursor_icon_names)); + Err(err) => { + errors.push_str(&err.to_string()); + errors.push('\n'); + } } } + if errors.is_empty() { + Err(anyhow!( + "errors while loading cursor icons {:?}:\n{}", + cursor_icon_names, + errors + )) + } else { + Err(anyhow!("did not find cursor icons {:?}", cursor_icon_names)) + } }; let cursor = match result { @@ -2031,6 +2047,73 @@ impl X11ClientState { self.cursor_cache.insert(style, cursor); cursor } + + fn get_or_create_invisible_cursor(&mut self) -> Option { + if let Some(cursor) = self.invisible_cursor_cache { + return Some(cursor); + } + let cursor = create_invisible_cursor(&self.xcb_connection) + .context("X11: error while creating invisible cursor") + .log_err()?; + self.invisible_cursor_cache = Some(cursor); + Some(cursor) + } + + fn hide_cursor_until_mouse_moves(&mut self) { + if self.cursor_hidden_window.is_some() { + return; + } + let Some(focused_window) = self.mouse_focused_window else { + // No window to apply the per-window invisible cursor to. + return; + }; + let Some(invisible_cursor) = self.get_or_create_invisible_cursor() else { + return; + }; + check_reply( + || "Failed to hide cursor", + self.xcb_connection.change_window_attributes( + focused_window, + &ChangeWindowAttributesAux { + cursor: Some(invisible_cursor), + ..Default::default() + }, + ), + ) + .log_err(); + self.xcb_connection.flush().log_err(); + self.cursor_hidden_window = Some(focused_window); + } + + fn restore_cursor_after_hide(&mut self) { + let Some(hidden_window) = self.cursor_hidden_window.take() else { + return; + }; + let style = self + .cursor_styles + .get(&hidden_window) + .copied() + .unwrap_or(CursorStyle::Arrow); + let Some(cursor) = self.get_cursor_icon(style) else { + log::warn!( + "X11: no cursor icon available to restore {:?} after hide; cursor may stay invisible", + style + ); + return; + }; + check_reply( + || "Failed to restore cursor style after hide", + self.xcb_connection.change_window_attributes( + hidden_window, + &ChangeWindowAttributesAux { + cursor: Some(cursor), + ..Default::default() + }, + ), + ) + .log_err(); + self.xcb_connection.flush().log_err(); + } } // Adapted from: diff --git a/crates/gpui_linux/src/linux/x11/display.rs b/crates/gpui_linux/src/linux/x11/display.rs index 900c55e..582d76f 100644 --- a/crates/gpui_linux/src/linux/x11/display.rs +++ b/crates/gpui_linux/src/linux/x11/display.rs @@ -38,7 +38,7 @@ impl X11Display { impl PlatformDisplay for X11Display { fn id(&self) -> DisplayId { - DisplayId::new(self.x_screen_index as u32) + DisplayId::new(self.x_screen_index as u64) } fn uuid(&self) -> anyhow::Result { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 6042386..2ecd878 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -340,7 +340,7 @@ impl rwh::HasDisplayHandle for X11Window { }; let screen_id = { let state = self.0.state.borrow(); - u32::from(state.display.id()) as i32 + u64::from(state.display.id()) as i32 }; let handle = rwh::XcbDisplayHandle::new(Some(non_zero), screen_id); Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) @@ -425,7 +425,7 @@ impl X11WindowState { ) -> anyhow::Result { let x_screen_index = params .display_id - .map_or(x_main_screen_index, |did| u32::from(did) as usize); + .map_or(x_main_screen_index, |did| u64::from(did) as usize); let visual_set = find_visuals(xcb, x_screen_index); diff --git a/crates/gpui_macos/src/display.rs b/crates/gpui_macos/src/display.rs index b9338bf..8e5db58 100644 --- a/crates/gpui_macos/src/display.rs +++ b/crates/gpui_macos/src/display.rs @@ -73,7 +73,7 @@ unsafe extern "C" { impl PlatformDisplay for MacDisplay { fn id(&self) -> DisplayId { - DisplayId::new(self.0) + DisplayId::new(self.0 as u64) } fn uuid(&self) -> Result { diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 0c3f929..0ee2fcd 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -50,7 +50,10 @@ use std::{ ptr, rc::Rc, slice, str, - sync::{Arc, OnceLock}, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, Ordering}, + }, }; use util::{ ResultExt, @@ -177,6 +180,8 @@ pub(crate) struct MacPlatformState { dock_menu: Option, menus: Option>, keyboard_mapper: Rc, + /// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose. + cursor_visible: Arc, } impl MacPlatform { @@ -213,6 +218,7 @@ impl MacPlatform { on_thermal_state_change: None, menus: None, keyboard_mapper, + cursor_visible: Arc::new(AtomicBool::new(true)), })) } @@ -617,9 +623,11 @@ impl Platform for MacPlatform { options: WindowParams, ) -> Result> { let renderer_context = self.0.lock().renderer_context.clone(); + let cursor_visible = self.0.lock().cursor_visible.clone(); Ok(Box::new(MacWindow::open( handle, options, + cursor_visible, self.foreground_executor(), self.background_executor(), renderer_context, @@ -976,11 +984,6 @@ impl Platform for MacPlatform { /// in macOS's [NSCursor](https://developer.apple.com/documentation/appkit/nscursor). fn set_cursor_style(&self, style: CursorStyle) { unsafe { - if style == CursorStyle::None { - let _: () = msg_send![class!(NSCursor), setHiddenUntilMouseMoves:YES]; - return; - } - let new_cursor: id = match style { CursorStyle::Arrow => msg_send![class!(NSCursor), arrowCursor], CursorStyle::IBeam => msg_send![class!(NSCursor), IBeamCursor], @@ -1015,7 +1018,6 @@ impl Platform for MacPlatform { CursorStyle::DragLink => msg_send![class!(NSCursor), dragLinkCursor], CursorStyle::DragCopy => msg_send![class!(NSCursor), dragCopyCursor], CursorStyle::ContextualMenu => msg_send![class!(NSCursor), contextualMenuCursor], - CursorStyle::None => unreachable!(), }; let old_cursor: id = msg_send![class!(NSCursor), currentCursor]; @@ -1025,6 +1027,20 @@ impl Platform for MacPlatform { } } + fn hide_cursor_until_mouse_moves(&self) { + let cursor_visible = self.0.lock().cursor_visible.clone(); + if !cursor_visible.swap(false, Ordering::Relaxed) { + return; + } + unsafe { + let _: () = msg_send![class!(NSCursor), setHiddenUntilMouseMoves: YES]; + } + } + + fn is_cursor_visible(&self) -> bool { + self.0.lock().cursor_visible.load(Ordering::Relaxed) + } + fn should_auto_hide_scrollbars(&self) -> bool { #[allow(non_upper_case_globals)] const NSScrollerStyleOverlay: NSInteger = 1; diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 6303faa..4d2c48b 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -445,6 +445,7 @@ struct MacWindowState { toggle_tab_bar_callback: Option>, activated_least_once: bool, closed: Arc, + cursor_visible: Arc, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, } @@ -616,6 +617,7 @@ impl MacWindow { window_min_size, tabbing_identifier, }: WindowParams, + cursor_visible: Arc, foreground_executor: ForegroundExecutor, background_executor: BackgroundExecutor, renderer_context: renderer::Context, @@ -770,6 +772,7 @@ impl MacWindow { toggle_tab_bar_callback: None, activated_least_once: false, closed: Arc::new(AtomicBool::new(false)), + cursor_visible, sheet_parent: None, }))); @@ -1984,6 +1987,20 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let event = unsafe { platform_input_from_native(native_event, Some(window_height)) }; if let Some(mut event) = event { + // AppKit unhides the cursor on the next mouse movement; mirror that here. + if matches!( + event, + PlatformInput::MouseMove(_) + | PlatformInput::MouseDown(_) + | PlatformInput::MouseUp(_) + | PlatformInput::MousePressure(_) + | PlatformInput::MouseExited(_) + | PlatformInput::ScrollWheel(_) + | PlatformInput::Pinch(_) + ) { + lock.cursor_visible.store(true, Ordering::Relaxed); + } + match &mut event { PlatformInput::MouseDown( event @ MouseDownEvent { @@ -2207,6 +2224,9 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let lock = window_state.lock(); let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; + // AppKit also unhides the cursor on activation changes, so mirror that here. + lock.cursor_visible.store(true, Ordering::Relaxed); + // When opening a pop-up while the application isn't active, Cocoa sends a spurious // `windowDidBecomeKey` message to the previous key window even though that window // isn't actually key. This causes a bug if the application is later activated while diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs index 133c9fd..3d8351f 100644 --- a/crates/gpui_macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -326,13 +326,6 @@ pub fn cursor_style_methods(input: TokenStream) -> TokenStream { self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeLeft); self } - - /// Sets cursor style when hovering over an element to `none`. - /// [Docs](https://tailwindcss.com/docs/cursor) - #visibility fn cursor_none(mut self, cursor: CursorStyle) -> Self { - self.style().mouse_cursor = Some(gpui::CursorStyle::None); - self - } }; output.into() diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index 9f5734e..d117ef6 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -1,5 +1,6 @@ use crate::dispatcher::WebDispatcher; use crate::display::WebDisplay; +use crate::events::WebEventListener; use crate::keyboard::WebKeyboardLayout; use crate::window::WebWindow; use anyhow::Result; @@ -13,11 +14,12 @@ use gpui::{ use gpui_wgpu::WgpuContext; use std::{ borrow::Cow, - cell::RefCell, + cell::{Cell, RefCell}, path::{Path, PathBuf}, rc::Rc, sync::Arc, }; +use wasm_bindgen::JsCast; static BUNDLED_FONTS: &[&[u8]] = &[ include_bytes!("../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"), @@ -39,6 +41,10 @@ pub struct WebPlatform { active_display: Rc, callbacks: RefCell, wgpu_context: Rc>>, + cursor_visible: Rc>, + last_cursor_css: Rc>, + #[allow(dead_code)] + cursor_restore_listeners: Vec, } #[derive(Default)] @@ -75,6 +81,13 @@ impl WebPlatform { } let text_system: Arc = text_system; let active_display: Rc = Rc::new(WebDisplay::new()); + let cursor_visible = Rc::new(Cell::new(true)); + let last_cursor_css = Rc::new(Cell::new("default")); + let cursor_restore_listeners = Self::cursor_restore_listeners( + &browser_window, + cursor_visible.clone(), + last_cursor_css.clone(), + ); Self { browser_window, @@ -85,8 +98,69 @@ impl WebPlatform { active_display, callbacks: RefCell::new(WebPlatformCallbacks::default()), wgpu_context: Rc::new(RefCell::new(None)), + cursor_visible, + last_cursor_css, + cursor_restore_listeners, } } + + fn cursor_restore_listeners( + browser_window: &web_sys::Window, + cursor_visible: Rc>, + last_cursor_css: Rc>, + ) -> Vec { + let mut listeners = Vec::new(); + let window_target: web_sys::EventTarget = browser_window.clone().unchecked_into(); + for event_name in ["mousemove", "mouseenter", "blur"] { + listeners.push(Self::cursor_restore_listener( + window_target.clone(), + event_name, + browser_window.clone(), + cursor_visible.clone(), + last_cursor_css.clone(), + )); + } + + if let Some(document) = browser_window.document() { + listeners.push(Self::cursor_restore_listener( + document.unchecked_into(), + "visibilitychange", + browser_window.clone(), + cursor_visible, + last_cursor_css, + )); + } + + listeners + } + + fn cursor_restore_listener( + target: web_sys::EventTarget, + event_name: &'static str, + browser_window: web_sys::Window, + cursor_visible: Rc>, + last_cursor_css: Rc>, + ) -> WebEventListener { + WebEventListener::new(target, event_name, move |_| { + if !cursor_visible.replace(true) { + Self::apply_cursor_css_to_window(&browser_window, last_cursor_css.get()); + } + }) + } + + fn apply_cursor_css_to_window(browser_window: &web_sys::Window, css_cursor: &str) { + if let Some(document) = browser_window.document() { + if let Some(body) = document.body() { + if let Err(error) = body.style().set_property("cursor", css_cursor) { + log::warn!("Failed to set cursor style: {error:?}"); + } + } + } + } + + fn apply_cursor_css(&self, css_cursor: &str) { + Self::apply_cursor_css_to_window(&self.browser_window, css_cursor); + } } impl Platform for WebPlatform { @@ -299,18 +373,23 @@ impl Platform for WebPlatform { CursorStyle::DragLink => "alias", CursorStyle::DragCopy => "copy", CursorStyle::ContextualMenu => "context-menu", - CursorStyle::None => "none", }; - if let Some(document) = self.browser_window.document() { - if let Some(body) = document.body() { - if let Err(error) = body.style().set_property("cursor", css_cursor) { - log::warn!("Failed to set cursor style: {error:?}"); - } - } + self.last_cursor_css.set(css_cursor); + if self.cursor_visible.get() { + self.apply_cursor_css(css_cursor); } } + fn hide_cursor_until_mouse_moves(&self) { + self.cursor_visible.set(false); + self.apply_cursor_css("none"); + } + + fn is_cursor_visible(&self) -> bool { + self.cursor_visible.get() + } + fn should_auto_hide_scrollbars(&self) -> bool { true } diff --git a/crates/gpui_windows/src/display.rs b/crates/gpui_windows/src/display.rs index 1931a69..3b81dc6 100644 --- a/crates/gpui_windows/src/display.rs +++ b/crates/gpui_windows/src/display.rs @@ -35,21 +35,19 @@ unsafe impl Sync for WindowsDisplay {} impl WindowsDisplay { pub(crate) fn new(display_id: DisplayId) -> Option { - let screen = available_monitors() - .into_iter() - .nth(u32::from(display_id) as _)?; - let info = get_monitor_info(screen).log_err()?; + let handle = HMONITOR(u64::from(display_id) as _); + let info = get_monitor_info(handle).log_err()?; let monitor_size = info.monitorInfo.rcMonitor; let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); - let scale_factor = get_scale_factor_for_monitor(screen).log_err()?; + let scale_factor = get_scale_factor_for_monitor(handle).log_err()?; let physical_size = size( (monitor_size.right - monitor_size.left).into(), (monitor_size.bottom - monitor_size.top).into(), ); Some(WindowsDisplay { - handle: screen, + handle, display_id, scale_factor, bounds: Bounds { @@ -76,86 +74,8 @@ impl WindowsDisplay { }) } - pub fn new_with_handle(monitor: HMONITOR) -> anyhow::Result { - let info = get_monitor_info(monitor)?; - let monitor_size = info.monitorInfo.rcMonitor; - let work_area = info.monitorInfo.rcWork; - let uuid = generate_uuid(&info.szDevice); - let display_id = available_monitors() - .iter() - .position(|handle| handle.0 == monitor.0) - .unwrap(); - let scale_factor = get_scale_factor_for_monitor(monitor)?; - let physical_size = size( - (monitor_size.right - monitor_size.left).into(), - (monitor_size.bottom - monitor_size.top).into(), - ); - - Ok(WindowsDisplay { - handle: monitor, - display_id: DisplayId::new(display_id as _), - scale_factor, - bounds: Bounds { - origin: logical_point( - monitor_size.left as f32, - monitor_size.top as f32, - scale_factor, - ), - size: physical_size.to_pixels(scale_factor), - }, - visible_bounds: Bounds { - origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), - size: size( - (work_area.right - work_area.left) as f32 / scale_factor, - (work_area.bottom - work_area.top) as f32 / scale_factor, - ) - .map(gpui::px), - }, - physical_bounds: Bounds { - origin: point(monitor_size.left.into(), monitor_size.top.into()), - size: physical_size, - }, - uuid, - }) - } - - fn new_with_handle_and_id(handle: HMONITOR, display_id: DisplayId) -> anyhow::Result { - let info = get_monitor_info(handle)?; - let monitor_size = info.monitorInfo.rcMonitor; - let work_area = info.monitorInfo.rcWork; - let uuid = generate_uuid(&info.szDevice); - let scale_factor = get_scale_factor_for_monitor(handle)?; - let physical_size = size( - (monitor_size.right - monitor_size.left).into(), - (monitor_size.bottom - monitor_size.top).into(), - ); - - Ok(WindowsDisplay { - handle, - display_id, - scale_factor, - bounds: Bounds { - origin: logical_point( - monitor_size.left as f32, - monitor_size.top as f32, - scale_factor, - ), - size: physical_size.to_pixels(scale_factor), - }, - visible_bounds: Bounds { - origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), - size: size( - (work_area.right - work_area.left) as f32 / scale_factor, - (work_area.bottom - work_area.top) as f32 / scale_factor, - ) - .map(gpui::px), - }, - physical_bounds: Bounds { - origin: point(monitor_size.left.into(), monitor_size.top.into()), - size: physical_size, - }, - uuid, - }) + pub(crate) fn display_id_for_monitor(monitor: HMONITOR) -> DisplayId { + DisplayId::new(monitor.0 as u64) } pub fn primary_monitor() -> Option { @@ -169,7 +89,7 @@ impl WindowsDisplay { ); return None; } - WindowsDisplay::new_with_handle(monitor).log_err() + WindowsDisplay::new(Self::display_id_for_monitor(monitor)) } /// Check if the center point of given bounds is inside this monitor @@ -183,7 +103,7 @@ impl WindowsDisplay { if monitor.is_invalid() { false } else { - let Ok(display) = WindowsDisplay::new_with_handle(monitor) else { + let Some(display) = WindowsDisplay::new(Self::display_id_for_monitor(monitor)) else { return false; }; display.uuid == self.uuid @@ -193,11 +113,11 @@ impl WindowsDisplay { pub fn displays() -> Vec> { available_monitors() .into_iter() - .enumerate() - .filter_map(|(id, handle)| { - Some(Rc::new( - WindowsDisplay::new_with_handle_and_id(handle, DisplayId::new(id as _)).ok()?, - ) as Rc) + .filter_map(|handle| { + Some( + Rc::new(WindowsDisplay::new(Self::display_id_for_monitor(handle))?) + as Rc, + ) }) .collect() } diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 2aa07d7..76d6c93 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{rc::Rc, sync::atomic::Ordering}; use ::util::ResultExt; use anyhow::Context as _; @@ -144,9 +144,9 @@ impl WindowsWindowInner { // monitor is invalid, we do nothing. if !monitor.is_invalid() && self.state.display.get().handle != monitor { // we will get the same monitor if we only have one - self.state - .display - .set(WindowsDisplay::new_with_handle(monitor).log_err()?); + self.state.display.set(WindowsDisplay::new( + WindowsDisplay::display_id_for_monitor(monitor), + )?); } } if let Some(mut callback) = self.state.callbacks.moved.take() { @@ -298,6 +298,7 @@ impl WindowsWindowInner { fn handle_mouse_move_msg(&self, handle: HWND, lparam: LPARAM, wparam: WPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE); + self.restore_cursor_after_hide(); let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); @@ -331,6 +332,9 @@ impl WindowsWindowInner { fn handle_mouse_leave_msg(&self) -> Option { self.state.hovered.set(false); + // The next window's `WM_SETCURSOR` picks its own cursor, so we just clear + // the flag for tight `is_cursor_visible()` semantics. + self.state.cursor_visible.store(true, Ordering::Relaxed); if let Some(mut callback) = self.state.callbacks.hovered_status_change.take() { callback(false); self.state @@ -726,6 +730,11 @@ impl WindowsWindowInner { fn handle_activate_msg(self: &Rc, wparam: WPARAM) -> Option { let activated = wparam.loword() > 0; let this = self.clone(); + + if !activated { + this.state.cursor_visible.store(true, Ordering::Relaxed); + } + self.executor .spawn(async move { if let Some(mut func) = this.state.callbacks.active_status_change.take() { @@ -840,7 +849,7 @@ impl WindowsWindowInner { log::error!("No monitor detected!"); return None; } - let new_display = WindowsDisplay::new_with_handle(new_monitor).log_err()?; + let new_display = WindowsDisplay::new(WindowsDisplay::display_id_for_monitor(new_monitor))?; self.state.display.set(new_display); Some(0) } @@ -910,6 +919,7 @@ impl WindowsWindowInner { fn handle_nc_mouse_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE | TME_NONCLIENT); + self.restore_cursor_after_hide(); let mut func = self.state.callbacks.input.take()?; let scale_factor = self.state.scale_factor.get(); @@ -1073,8 +1083,13 @@ impl WindowsWindowInner { { return None; } + let cursor = if self.state.cursor_visible.load(Ordering::Relaxed) { + self.state.current_cursor.get() + } else { + None + }; unsafe { - SetCursor(self.state.current_cursor.get()); + SetCursor(cursor); }; Some(0) } @@ -1227,6 +1242,15 @@ impl WindowsWindowInner { } } + /// Clear the hidden flag and restore the cursor immediately + fn restore_cursor_after_hide(&self) { + if !self.state.cursor_visible.swap(true, Ordering::Relaxed) { + unsafe { + SetCursor(self.state.current_cursor.get()); + } + } + } + fn start_tracking_mouse(&self, handle: HWND, flags: TRACKMOUSEEVENT_FLAGS) { if !self.state.hovered.get() { self.state.hovered.set(true); diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 1821071..c98af22 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -63,6 +63,8 @@ pub(crate) struct WindowsPlatformState { jump_list: RefCell, // NOTE: standard cursor handles don't need to close. pub(crate) current_cursor: Cell>, + /// Shared with each window so `WM_SETCURSOR` can read it directly. + pub(crate) cursor_visible: Arc, directx_devices: RefCell>, } @@ -87,6 +89,7 @@ impl WindowsPlatformState { callbacks, jump_list: RefCell::new(jump_list), current_cursor: Cell::new(current_cursor), + cursor_visible: Arc::new(AtomicBool::new(true)), directx_devices: RefCell::new(directx_devices), menus: RefCell::new(Vec::new()), } @@ -219,6 +222,7 @@ impl WindowsPlatform { icon: self.icon, executor: self.foreground_executor.clone(), current_cursor: self.inner.state.current_cursor.get(), + cursor_visible: self.inner.state.cursor_visible.clone(), drop_target_helper: self.drop_target_helper.clone().unwrap(), validation_number: self.inner.validation_number, main_receiver: self.inner.main_receiver.clone(), @@ -675,6 +679,31 @@ impl Platform for WindowsPlatform { should_auto_hide_scrollbars().log_err().unwrap_or(false) } + fn hide_cursor_until_mouse_moves(&self) { + if !self + .inner + .state + .cursor_visible + .swap(false, Ordering::Relaxed) + { + return; + } + + for handle in self.raw_window_handles.read().iter() { + let Some(window) = window_from_hwnd(handle.as_raw()) else { + continue; + }; + if window.state.hovered.get() { + unsafe { SetCursor(None) }; + break; + } + } + } + + fn is_cursor_visible(&self) -> bool { + self.inner.state.cursor_visible.load(Ordering::Relaxed) + } + fn write_to_clipboard(&self, item: ClipboardItem) { write_to_clipboard(item); } @@ -1004,6 +1033,7 @@ pub(crate) struct WindowCreationInfo { pub(crate) icon: HICON, pub(crate) executor: ForegroundExecutor, pub(crate) current_cursor: Option, + pub(crate) cursor_visible: Arc, pub(crate) drop_target_helper: IDropTargetHelper, pub(crate) validation_number: usize, pub(crate) main_receiver: PriorityQueueReceiver, diff --git a/crates/gpui_windows/src/util.rs b/crates/gpui_windows/src/util.rs index fe5093d..a883235 100644 --- a/crates/gpui_windows/src/util.rs +++ b/crates/gpui_windows/src/util.rs @@ -1,7 +1,7 @@ use std::sync::OnceLock; -use ::util::ResultExt; use anyhow::Context; +use util::ResultExt; use windows::{ UI::{ Color, @@ -115,7 +115,6 @@ pub(crate) fn load_cursor(style: CursorStyle) -> Option { CursorStyle::ResizeUpLeftDownRight => (&SIZENWSE, IDC_SIZENWSE), CursorStyle::ResizeUpRightDownLeft => (&SIZENESW, IDC_SIZENESW), CursorStyle::OperationNotAllowed => (&NO, IDC_NO), - CursorStyle::None => return None, _ => (&ARROW, IDC_ARROW), }; Some( diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 54c12a5..35648ec 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -69,6 +69,8 @@ pub struct WindowsWindowState { pub click_state: ClickState, pub current_cursor: Cell>, + /// Shared with [`WindowsPlatformState::cursor_visible`]. + pub cursor_visible: Arc, pub nc_button_pressed: Cell>, pub display: Cell, @@ -101,6 +103,7 @@ impl WindowsWindowState { directx_devices: &DirectXDevices, window_params: &CREATESTRUCTW, current_cursor: Option, + cursor_visible: Arc, display: WindowsDisplay, min_size: Option>, appearance: WindowAppearance, @@ -161,6 +164,7 @@ impl WindowsWindowState { renderer: RefCell::new(renderer), click_state, current_cursor: Cell::new(current_cursor), + cursor_visible, nc_button_pressed: Cell::new(nc_button_pressed), display: Cell::new(display), fullscreen: Cell::new(fullscreen), @@ -237,6 +241,7 @@ impl WindowsWindowInner { &context.directx_devices, cs, context.current_cursor, + context.cursor_visible.clone(), context.display, context.min_size, context.appearance, @@ -376,6 +381,7 @@ struct WindowCreateContext { min_size: Option>, executor: ForegroundExecutor, current_cursor: Option, + cursor_visible: Arc, drop_target_helper: IDropTargetHelper, validation_number: usize, main_receiver: PriorityQueueReceiver, @@ -397,6 +403,7 @@ impl WindowsWindow { icon, executor, current_cursor, + cursor_visible, drop_target_helper, validation_number, main_receiver, @@ -461,11 +468,12 @@ impl WindowsWindow { let hinstance = get_module_handle(); let display = if let Some(display_id) = params.display_id { - // if we obtain a display_id, then this ID must be valid. - WindowsDisplay::new(display_id).unwrap() + WindowsDisplay::new(display_id) } else { - WindowsDisplay::primary_monitor().unwrap() - }; + None + } + .or_else(WindowsDisplay::primary_monitor) + .context("failed to find any monitor")?; let appearance = system_appearance().unwrap_or_default(); let mut context = WindowCreateContext { inner: None, @@ -476,6 +484,7 @@ impl WindowsWindow { min_size: params.window_min_size, executor, current_cursor, + cursor_visible, drop_target_helper, validation_number, main_receiver, From f6eaf7e4709a321ef67c4707bf03a5d424c1824d Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Thu, 14 May 2026 23:22:56 -0700 Subject: [PATCH 03/22] fix: address PR review comments --- crates/gpui/src/elements/list.rs | 68 ++++++++++++++++++- crates/gpui/src/executor.rs | 3 +- crates/gpui/src/prelude.rs | 6 +- crates/gpui_linux/src/linux/wayland/client.rs | 8 +-- crates/gpui_linux/src/linux/x11/client.rs | 4 +- crates/gpui_linux/src/linux/x11/window.rs | 11 ++- 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 95dd63b..d4385c9 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -395,7 +395,7 @@ impl ListState { cursor .item() .and_then(|item| { - item.size().map(|size| { + item.size_hint().map(|size| { let fraction = if size.height.0 > 0.0 { (scroll_top.offset_in_item.0 / size.height.0) .clamp(0.0, 1.0) @@ -409,7 +409,14 @@ impl ListState { }) }) }) - .or_else(|| state.pending_scroll.clone()) + .or_else(|| match &state.pending_scroll { + Some(PendingScroll::Proportional(pending_scroll)) + if pending_scroll.item_ix == scroll_top.item_ix => + { + Some(PendingScroll::Proportional(pending_scroll.clone())) + } + _ => None, + }) } }; } @@ -1783,6 +1790,63 @@ mod test { assert_eq!(offset.offset_in_item, px(40.)); } + #[gpui::test] + fn test_remeasure_uses_proportional_anchor_after_pending_item_remeasure( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 5 { height } else { 100 }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.remeasure_items(5..6); + item_height.set(50); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(20.)); + } + #[gpui::test] fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index c58f460..c08d4f4 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -36,7 +36,8 @@ pub struct ForegroundExecutor { /// Extension trait for `Task>` that adds `detach_and_log_err` with an `&App` context. /// -/// This trait is automatically implemented for all `Task>` types. +/// This trait is implemented for `Task>` where `T: 'static` and +/// `E: 'static + std::fmt::Display + std::fmt::Debug`. pub trait TaskExt { /// Run the task to completion in the background and log any errors that occur. fn detach_and_log_err(self, cx: &App); diff --git a/crates/gpui/src/prelude.rs b/crates/gpui/src/prelude.rs index 284464d..b5185a2 100644 --- a/crates/gpui/src/prelude.rs +++ b/crates/gpui/src/prelude.rs @@ -3,7 +3,7 @@ //! application to avoid having to import each trait individually. pub use crate::{ - util::FluentBuilder, AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, - IntoElement, ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, - StyledImage, TaskExt as _, VisualContext, + AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, IntoElement, + ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, StyledImage, + TaskExt as _, VisualContext, util::FluentBuilder, }; diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 8f7d6b5..b89daeb 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -430,15 +430,14 @@ impl WaylandClientState { } fn restore_cursor_after_hide(&mut self) { - if self.cursor_hidden_window.take().is_none() { + if self.cursor_hidden_window.is_none() { return; } - let Some(style) = self.cursor_style else { - return; - }; + let style = self.cursor_style.unwrap_or(CursorStyle::Arrow); let serial = self.serial_tracker.get(SerialKind::MouseEnter); if let Some(cursor_shape_device) = &self.cursor_shape_device { cursor_shape_device.set_shape(serial, to_shape(style)); + self.cursor_hidden_window = None; return; } let Some(focused_window) = self.mouse_focused_window.clone() else { @@ -462,6 +461,7 @@ impl WaylandClientState { cursor_style_to_icon_names(style), scale, ); + self.cursor_hidden_window = None; } } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index e2d794f..2cff8c5 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -2009,13 +2009,13 @@ impl X11ClientState { } } if errors.is_empty() { + Err(anyhow!("did not find cursor icons {:?}", cursor_icon_names)) + } else { Err(anyhow!( "errors while loading cursor icons {:?}:\n{}", cursor_icon_names, errors )) - } else { - Err(anyhow!("did not find cursor icons {:?}", cursor_icon_names)) } }; diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 2ecd878..a91af78 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -423,9 +423,14 @@ impl X11WindowState { supports_xinput_gestures: bool, is_bgr: bool, ) -> anyhow::Result { - let x_screen_index = params - .display_id - .map_or(x_main_screen_index, |did| u64::from(did) as usize); + let x_screen_index = params.display_id.map_or(Ok(x_main_screen_index), |did| { + let index = + usize::try_from(u64::from(did)).context("X11 display id does not fit in usize")?; + xcb.setup().roots.get(index).with_context(|| { + format!("no X11 screen found for display id {}", u64::from(did)) + })?; + Ok(index) + })?; let visual_set = find_visuals(xcb, x_screen_index); From c4e8793e18bc0c85aee43015ea58102960368300 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Fri, 15 May 2026 00:06:24 -0700 Subject: [PATCH 04/22] fix: preserve X11 cursor restore retry state --- crates/gpui_linux/src/linux/x11/client.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 2cff8c5..56d8b89 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -2086,7 +2086,7 @@ impl X11ClientState { } fn restore_cursor_after_hide(&mut self) { - let Some(hidden_window) = self.cursor_hidden_window.take() else { + let Some(hidden_window) = self.cursor_hidden_window else { return; }; let style = self @@ -2101,7 +2101,7 @@ impl X11ClientState { ); return; }; - check_reply( + let restore_result = check_reply( || "Failed to restore cursor style after hide", self.xcb_connection.change_window_attributes( hidden_window, @@ -2111,8 +2111,17 @@ impl X11ClientState { }, ), ) + .and_then(|()| { + self.xcb_connection + .flush() + .map(|_| ()) + .map_err(handle_connection_error) + .context("X11 flush failed") + }) .log_err(); - self.xcb_connection.flush().log_err(); + if restore_result.is_some() { + self.cursor_hidden_window = None; + } } } From 58ab03f4f5e04ac9974f1918a6250d825b87f689 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Fri, 15 May 2026 00:10:16 -0700 Subject: [PATCH 05/22] fix: make X11 display validation type explicit --- crates/gpui_linux/src/linux/x11/window.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index a91af78..e1705c5 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -423,14 +423,17 @@ impl X11WindowState { supports_xinput_gestures: bool, is_bgr: bool, ) -> anyhow::Result { - let x_screen_index = params.display_id.map_or(Ok(x_main_screen_index), |did| { - let index = - usize::try_from(u64::from(did)).context("X11 display id does not fit in usize")?; - xcb.setup().roots.get(index).with_context(|| { - format!("no X11 screen found for display id {}", u64::from(did)) - })?; - Ok(index) - })?; + let x_screen_index = match params.display_id { + Some(did) => { + let index = usize::try_from(u64::from(did)) + .context("X11 display id does not fit in usize")?; + xcb.setup().roots.get(index).with_context(|| { + format!("no X11 screen found for display id {}", u64::from(did)) + })?; + index + } + None => x_main_screen_index, + }; let visual_set = find_visuals(xcb, x_screen_index); From 8164718110bda8e87a122041b727093eccbf7b16 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 09:09:55 -0700 Subject: [PATCH 06/22] feat: sync upstream gpui updates --- Cargo.lock | 688 +++++++++++++-- Cargo.toml | 9 +- crates/gpui/Cargo.toml | 1 + crates/gpui/README.md | 1 + crates/gpui/examples/a11y.rs | 210 +++++ crates/gpui/examples/opacity.rs | 1 + crates/gpui/examples/shadow.rs | 130 +++ crates/gpui/examples/window_shadow.rs | 2 + crates/gpui/src/_accessibility.rs | 80 ++ crates/gpui/src/app.rs | 15 + crates/gpui/src/app/async_context.rs | 30 +- crates/gpui/src/element.rs | 37 + crates/gpui/src/elements/div.rs | 794 ++++++++++++++++++ crates/gpui/src/elements/list.rs | 146 ++++ crates/gpui/src/elements/retained_layer.rs | 1 + .../gpui/src/elements/retained_layer_tests.rs | 183 +++- crates/gpui/src/elements/text.rs | 195 ++++- crates/gpui/src/executor.rs | 31 +- crates/gpui/src/gpui.rs | 5 + crates/gpui/src/platform.rs | 19 + crates/gpui/src/platform/test/dispatcher.rs | 3 +- crates/gpui/src/platform_scheduler.rs | 38 +- crates/gpui/src/scene.rs | 6 + crates/gpui/src/style.rs | 36 +- crates/gpui/src/text_system.rs | 22 + crates/gpui/src/text_system/line_wrapper.rs | 609 +++++++++++++- crates/gpui/src/view.rs | 1 + crates/gpui/src/window.rs | 256 +++++- crates/gpui/src/window/a11y.rs | 708 ++++++++++++++++ crates/gpui_linux/Cargo.toml | 2 + crates/gpui_linux/src/linux/wayland/client.rs | 6 +- crates/gpui_linux/src/linux/wayland/serial.rs | 18 + crates/gpui_linux/src/linux/wayland/window.rs | 60 ++ crates/gpui_linux/src/linux/x11/window.rs | 86 ++ crates/gpui_macos/Cargo.toml | 2 + crates/gpui_macos/src/platform.rs | 8 +- crates/gpui_macos/src/screen_capture.rs | 2 +- crates/gpui_macos/src/shaders.metal | 27 +- crates/gpui_macos/src/window.rs | 67 +- crates/gpui_macros/src/styles.rs | 11 + crates/gpui_web/src/window.rs | 1 + crates/gpui_wgpu/Cargo.toml | 10 +- crates/gpui_wgpu/benches/layout_line.rs | 87 ++ crates/gpui_wgpu/src/cosmic_text_system.rs | 471 ++++++++++- crates/gpui_wgpu/src/shaders.wgsl | 72 +- crates/gpui_wgpu/src/wgpu_renderer.rs | 16 +- crates/gpui_windows/Cargo.toml | 2 + crates/gpui_windows/src/events.rs | 26 + crates/gpui_windows/src/shaders.hlsl | 63 +- crates/gpui_windows/src/window.rs | 60 ++ crates/scheduler/src/executor.rs | 189 ++++- crates/scheduler/src/scheduler.rs | 56 +- crates/scheduler/src/test_scheduler.rs | 56 +- crates/scheduler/src/tests.rs | 278 +++++- crates/sum_tree/src/sum_tree.rs | 2 +- crates/util/src/command/darwin.rs | 80 +- 56 files changed, 5742 insertions(+), 273 deletions(-) create mode 100644 crates/gpui/examples/a11y.rs create mode 100644 crates/gpui/src/_accessibility.rs create mode 100644 crates/gpui/src/window/a11y.rs create mode 100644 crates/gpui_wgpu/benches/layout_line.rs diff --git a/Cargo.lock b/Cargo.lock index 7774a63..c542c54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,95 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5c87e8d94f2ec10cce590aadff24c76f576dab5502d45d0aed9fc3065d4451" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel 2.5.0", + "async-executor", + "async-task", + "atspi", + "futures-lite 2.6.1", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -75,6 +164,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -453,6 +548,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -644,13 +776,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.3", ] [[package]] @@ -760,6 +901,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbc" version = "0.1.2" @@ -859,6 +1006,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -887,6 +1061,31 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.56" @@ -1240,6 +1439,30 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.11.0", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.1", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1258,6 +1481,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1311,20 +1570,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.4.3" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - [[package]] name = "data-url" version = "0.3.2" @@ -1451,9 +1704,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -1491,21 +1744,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dtor" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" - [[package]] name = "dunce" version = "1.0.5" @@ -2279,6 +2517,7 @@ dependencies = [ name = "gpui" version = "0.2.2" dependencies = [ + "accesskit", "anyhow", "async-channel 2.5.0", "async-task", @@ -2323,8 +2562,8 @@ dependencies = [ "naga 25.0.1", "num_cpus", "objc", - "objc2", - "objc2-metal", + "objc2 0.6.3", + "objc2-metal 0.3.2", "parking", "parking_lot", "pathfinder_geometry", @@ -2370,6 +2609,8 @@ dependencies = [ name = "gpui_linux" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", @@ -2378,7 +2619,7 @@ dependencies = [ "calloop", "calloop-wayland-source", "collections", - "cosmic-text", + "cosmic-text 0.17.2", "filedescriptor", "futures", "gpui", @@ -2417,6 +2658,8 @@ dependencies = [ name = "gpui_macos" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_macos", "anyhow", "async-task", "block", @@ -2534,7 +2777,8 @@ dependencies = [ "anyhow", "bytemuck", "collections", - "cosmic-text", + "cosmic-text 0.19.0", + "criterion", "etagere", "gpui", "gpui_util", @@ -2547,6 +2791,7 @@ dependencies = [ "raw-window-handle", "smallvec", "swash", + "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -2558,6 +2803,8 @@ dependencies = [ name = "gpui_windows" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_windows", "anyhow", "collections", "etagere", @@ -3124,6 +3371,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -3140,6 +3398,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3370,6 +3637,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +[[package]] +name = "link-section" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3689,8 +3968,8 @@ dependencies = [ [[package]] name = "naga" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -3928,6 +4207,22 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.3" @@ -3937,6 +4232,34 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3945,7 +4268,19 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -3954,6 +4289,18 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -3961,10 +4308,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-metal" version = "0.3.2" @@ -3972,11 +4331,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.6.2", "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -3986,10 +4358,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.11.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -4066,6 +4438,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.3.2" @@ -4193,6 +4571,49 @@ dependencies = [ "serde_json", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -4248,6 +4669,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.16" @@ -4486,6 +4935,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4689,10 +5148,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -6024,6 +6483,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -6112,7 +6581,7 @@ dependencies = [ "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -6144,7 +6613,7 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -6156,7 +6625,7 @@ dependencies = [ "indexmap", "toml_datetime 0.7.3", "toml_parser", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -6165,7 +6634,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -6883,8 +7352,8 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bitflags 2.11.0", @@ -6895,7 +7364,7 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", - "naga 29.0.0", + "naga 29.0.3", "parking_lot", "portable-atomic", "profiling", @@ -6912,8 +7381,8 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -6925,7 +7394,7 @@ dependencies = [ "hashbrown 0.16.1", "indexmap", "log", - "naga 29.0.0", + "naga 29.0.3", "once_cell", "parking_lot", "portable-atomic", @@ -6944,39 +7413,39 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set 0.9.1", "bitflags 2.11.0", - "block2", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", @@ -6990,13 +7459,13 @@ dependencies = [ "libc", "libloading", "log", - "naga 29.0.0", + "naga 29.0.3", "ndk-sys", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", "parking_lot", @@ -7016,21 +7485,22 @@ dependencies = [ "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] name = "wgpu-naga-bridge" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ - "naga 29.0.0", + "naga 29.0.3", "wgpu-types", ] [[package]] name = "wgpu-types" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "bitflags 2.11.0", "bytemuck", @@ -7685,6 +8155,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.55.0" @@ -7910,12 +8389,36 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.13", "zbus_macros", "zbus_names", "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.12.0" @@ -7933,13 +8436,24 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "static_assertions", - "winnow", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +dependencies = [ + "quick-xml 0.39.4", + "serde", + "zbus_names", "zvariant", ] @@ -8227,24 +8741,24 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.8.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -8255,13 +8769,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", "serde", "syn", - "winnow", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 7b63fd8..29c258f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,10 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # +accesskit = "0.24.0" +accesskit_macos = "0.26.0" +accesskit_unix = "0.21.0" +accesskit_windows = "0.32.1" anyhow = "1.0.86" ashpd = { version = "0.12", default-features = false, features = ["async-std"] } async-channel = "2.5.0" @@ -92,7 +96,8 @@ cocoa-foundation = "=0.2.0" core-foundation = "=0.10.0" core-foundation-sys = "0.8.6" core-video = { version = "0.4.3", features = ["metal"] } -ctor = "0.4.0" +criterion = { version = "0.5", features = ["html_reports"] } +ctor = "1.0.6" derive_more = "0.99.17" dirs = "4.0" env_logger = "0.11" @@ -171,7 +176,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } walkdir = "2.5" wasm-bindgen = "0.2.113" web-time = "1.1.0" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "a466bc382ea747f8e1ac810efdb6dcd49a514575" } +wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } which = "6.0.0" windows-core = "0.61" windows-numerics = "0.2" diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index c41b88c..db18208 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -38,6 +38,7 @@ path = "src/gpui.rs" doctest = false [dependencies] +accesskit.workspace = true anyhow.workspace = true async-task = "4.7" async-channel.workspace = true diff --git a/crates/gpui/README.md b/crates/gpui/README.md index ad3fd37..4d4ca76 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -12,6 +12,7 @@ gpui = { version = "*" } ``` - [Ownership and data flow](_ownership_and_data_flow) + - [Accessibility](_accessibility) Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs new file mode 100644 index 0000000..5478a02 --- /dev/null +++ b/crates/gpui/examples/a11y.rs @@ -0,0 +1,210 @@ +//! Accessibility (AccessKit) demo app. +//! +//! Run with: `cargo run -p gpui --example a11y`. + +use gpui::{ + AccessibleAction, App, Context, FocusHandle, KeyBinding, Role, SharedString, Toggled, Window, + WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, text, +}; +use gpui_platform::application; + +actions!(a11y_example, [Tab, TabPrev]); + +struct A11yDemo { + focus_handle: FocusHandle, + count: i32, + enabled: bool, +} + +impl A11yDemo { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle, cx); + Self { + focus_handle, + count: 0, + enabled: false, + } + } +} + +impl Render for A11yDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("root") + .role(Role::Application) + .aria_label("Accessibility Demo") + .track_focus(&self.focus_handle) + .on_action(cx.listener(|_, _: &Tab, window, cx| window.focus_next(cx))) + .on_action(cx.listener(|_, _: &TabPrev, window, cx| window.focus_prev(cx))) + .size_full() + .flex() + .flex_col() + .gap_4() + .p_4() + .bg(rgb(0x1e1e2e)) + .text_color(rgb(0xcdd6f4)) + .child( + div() + .id("heading") + .role(Role::Heading) + .aria_level(1) + .aria_label("Accessibility Demo") + .text_xl() + .font_weight(gpui::FontWeight::BOLD) + .child(text!("Accessibility Demo")), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .id("counter") + .focusable() + .tab_stop(true) + .role(Role::SpinButton) + .aria_label(SharedString::from(format!("Counter: {}", self.count))) + .aria_numeric_value(self.count as f64) + .aria_min_numeric_value(0.0) + .on_a11y_action(AccessibleAction::Increment, { + let this = cx.entity().downgrade(); + move |_, _, cx| { + this.update(cx, |this, cx| { + this.count += 1; + cx.notify(); + }) + .ok(); + } + }) + .on_a11y_action(AccessibleAction::Decrement, { + let this = cx.entity().downgrade(); + move |_, _, cx| { + this.update(cx, |this, cx| { + this.count = (this.count - 1).max(0); + cx.notify(); + }) + .ok(); + } + }) + .on_click(cx.listener(|this, _, _, cx| { + this.count += 1; + cx.notify(); + })) + .px_3() + .py_1() + .rounded_md() + .bg(rgb(0x89b4fa)) + .text_color(rgb(0x1e1e2e)) + .cursor_pointer() + .child(text!(format!("Count: {}", self.count))), + ) + .child( + div() + .id("reset") + .focusable() + .tab_stop(true) + .role(Role::Button) + .aria_label("Reset counter") + .px_3() + .py_1() + .rounded_md() + .bg(rgb(0x585b70)) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| { + this.count = 0; + cx.notify(); + })) + .child(text!("Reset")), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .id("toggle") + .focusable() + .tab_stop(true) + .role(Role::Switch) + .aria_label("Enable feature") + .aria_toggled(if self.enabled { + Toggled::True + } else { + Toggled::False + }) + .w(px(44.)) + .h(px(24.)) + .rounded_full() + .cursor_pointer() + .when(self.enabled, |el| el.bg(rgb(0x89b4fa))) + .when(!self.enabled, |el| el.bg(rgb(0x585b70))) + .child( + div() + .size(px(20.)) + .rounded_full() + .bg(gpui::white()) + .mt(px(2.)) + .when(self.enabled, |el| el.ml(px(22.))) + .when(!self.enabled, |el| el.ml(px(2.))), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.enabled = !this.enabled; + cx.notify(); + })), + ) + .child(text!("Enable feature")), + ) + .child( + div() + .id("task-list") + .role(Role::List) + .aria_label("Tasks") + .flex() + .flex_col() + .gap_1() + .children( + ["Write code", "Run tests", "Ship it"] + .iter() + .enumerate() + .map(|(i, label)| { + div() + .id(("task", i)) + .role(Role::ListItem) + .aria_label(SharedString::from(*label)) + .aria_position_in_set(i + 1) + .aria_size_of_set(3) + .py_1() + .px_2() + .child(text!(format!("{}. {}", i + 1, label))) + }), + ), + ) + } +} + +fn main() { + application().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("tab", Tab, None), + KeyBinding::new("shift-tab", TabPrev, None), + ]); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::centered(size(px(640.), px(420.)), cx)), + titlebar: Some(gpui::TitlebarOptions { + title: Some("GPUI Accessibility Demo".into()), + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| A11yDemo::new(window, cx)), + ) + .unwrap(); + cx.activate(true); + }); +} diff --git a/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index 31094f4..a9faf38 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -117,6 +117,7 @@ impl Render for HelloWorld { blur_radius: px(1.0), spread_radius: px(5.0), offset: point(px(10.0), px(10.0)), + inset: false, }]) .child(img("image/app-icon.png").size_8()) .child("Opacity Panel (Click to test)") diff --git a/crates/gpui/examples/shadow.rs b/crates/gpui/examples/shadow.rs index 519053a..f2afe7a 100644 --- a/crates/gpui/examples/shadow.rs +++ b/crates/gpui/examples/shadow.rs @@ -107,6 +107,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -117,6 +118,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -127,6 +129,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -137,6 +140,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -147,6 +151,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -183,6 +188,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -192,6 +198,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(2.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -201,6 +208,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(4.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -210,6 +218,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -219,6 +228,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(16.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -235,6 +245,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -244,6 +255,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }]), ), example( @@ -253,6 +265,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(4.), + inset: false, }]), ), example( @@ -262,6 +275,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(8.), + inset: false, }]), ), example( @@ -271,6 +285,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(16.), + inset: false, }]), ), ]), @@ -287,6 +302,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -296,6 +312,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(8.), + inset: false, }]), ), example( @@ -305,6 +322,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(16.), + inset: false, }]), ), ]), @@ -321,6 +339,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -330,6 +349,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(8.), + inset: false, }]), ), example( @@ -339,6 +359,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(16.), + inset: false, }]), ), ]), @@ -355,6 +376,7 @@ impl Render for Shadow { offset: point(px(-8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -364,6 +386,7 @@ impl Render for Shadow { offset: point(px(8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -373,6 +396,7 @@ impl Render for Shadow { offset: point(px(0.), px(-8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -382,6 +406,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -398,6 +423,7 @@ impl Render for Shadow { offset: point(px(-8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -407,6 +433,7 @@ impl Render for Shadow { offset: point(px(8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -416,6 +443,7 @@ impl Render for Shadow { offset: point(px(0.), px(-8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -425,6 +453,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -441,6 +470,7 @@ impl Render for Shadow { offset: point(px(-8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -450,6 +480,7 @@ impl Render for Shadow { offset: point(px(8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -459,6 +490,7 @@ impl Render for Shadow { offset: point(px(0.), px(-8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -468,6 +500,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -485,24 +518,28 @@ impl Render for Shadow { offset: point(px(0.), px(-12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow offset: point(px(12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green offset: point(px(0.), px(12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue offset: point(px(-12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, ]), ), @@ -514,24 +551,28 @@ impl Render for Shadow { offset: point(px(0.), px(-12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow offset: point(px(12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green offset: point(px(0.), px(12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue offset: point(px(-12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, ]), ), @@ -543,24 +584,113 @@ impl Render for Shadow { offset: point(px(0.), px(-12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow offset: point(px(12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green offset: point(px(0.), px(12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue offset: point(px(-12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, + }, + ]), + ), + ]), + // Inset shadows (CSS `box-shadow: inset ...`). + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![ + example( + "Inset basic", + Shadow::base().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.5), + offset: point(px(0.), px(0.)), + blur_radius: px(12.), + spread_radius: px(0.), + inset: true, + }]), + ), + example( + "Inset offset", + Shadow::base().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.5), + offset: point(px(6.), px(6.)), + blur_radius: px(8.), + spread_radius: px(0.), + inset: true, + }]), + ), + example( + "Inset spread", + Shadow::base().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.5), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(8.), + inset: true, + }]), + ), + example( + "Inset rounded", + Shadow::rounded_large().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.5), + offset: point(px(0.), px(4.)), + blur_radius: px(10.), + spread_radius: px(2.), + inset: true, + }]), + ), + example( + "Inset sharp", + Shadow::square().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.6), + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(6.), + inset: true, + }]), + ), + ]), + // Combined: drop + inset shadows on the same element. + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![ + example( + "Drop + Inset", + Shadow::rounded_medium().shadow(vec![ + BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.25), + offset: point(px(0.), px(8.)), + blur_radius: px(12.), + spread_radius: px(0.), + inset: false, + }, + BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.4), + offset: point(px(0.), px(2.)), + blur_radius: px(4.), + spread_radius: px(0.), + inset: true, }, ]), ), diff --git a/crates/gpui/examples/window_shadow.rs b/crates/gpui/examples/window_shadow.rs index c8e37b6..3f3cdb3 100644 --- a/crates/gpui/examples/window_shadow.rs +++ b/crates/gpui/examples/window_shadow.rs @@ -114,6 +114,7 @@ impl Render for WindowShadow { }, blur_radius: shadow_size / 2., spread_radius: px(0.), + inset: false, offset: point(px(0.0), px(0.0)), }]) }), @@ -154,6 +155,7 @@ impl Render for WindowShadow { }, blur_radius: px(20.0), spread_radius: px(0.0), + inset: false, offset: point(px(0.0), px(0.0)), }]) .map(|div| match decorations { diff --git a/crates/gpui/src/_accessibility.rs b/crates/gpui/src/_accessibility.rs new file mode 100644 index 0000000..f20a4ab --- /dev/null +++ b/crates/gpui/src/_accessibility.rs @@ -0,0 +1,80 @@ +//! # Accessibility in GPUI +//! +//! "Accessibility" refers to the ability of your application to be used by all +//! users, regardless of disability status. There are many aspects, all important, including: +//! - Ensuring sufficient text contrast. +//! - Providing a mechanism to disable animations. +//! - Providing a mechanism to increase text sizes. +//! - etc. +//! +//! This guide is focused on **programmatic accessibility**. This allows +//! assistive technology, such as screen readers or Braille displays, to inspect +//! and interact with your app. +//! +//! GPUI integrates with [AccessKit] to provide programmatic accessibility +//! features (referred to as simply "accessibility" for the rest of this guide). +//! +//! A minimal example can be found in the `examples/a11y` directory. +//! +//! ## Background +//! +//! Accessibility support is based on two key capabilities: +//! - Exposing information about the current UI state to assistive technology. +//! - Responding to actions requested by assistive technology. +//! +//! ### IDs in GPUI - [`ElementId`] and [`GlobalElementId`] +//! +//! In GPUI, each [`Element`] can have an [`id`][Element::id]. [`Element`]s with +//! IDs are also assigned a [`GlobalElementId`], formed by composing all +//! non-`None` IDs of its ancestors. These IDs should be unique per frame. +//! +//! ### IDs and accessibility +//! +//! When GPUI renders a frame, it walks your UI tree and informs assistive +//! technology about nodes with global IDs and non-`None` +//! [`role`][Element::a11y_role] values. Use +//! [`div().id(...).role()`][StatefulInteractiveElement::role] to set a role. +//! +//! Nodes with the same global ID across frames are considered to be the same +//! node. If a node's ID changes, assistive technology treats it as one node +//! being removed and another being added, which can be disorienting for users. +//! +//! #### IDs and text +//! +//! GPUI provides the [`text!`] macro, which wraps strings in the [`Text`] type +//! and automatically derives an ID from the source location of the macro +//! invocation. This means repeated calls through the same helper function can +//! produce duplicate text IDs. Set IDs explicitly with [`Text::with_id`] or wrap +//! each text element in a parent with a unique ID when rendering collections. +//! +//! Occasionally, you will need to create a [`Text`] element with no ID. You can +//! achieve this with [`Text::new_inaccessible`]. If you are creating a custom UI +//! component, this can avoid duplicating text that is already represented on a +//! parent node's accessible label. +//! +//! ### Handling actions +//! +//! Assistive technology can dispatch actions to a specific node. AccessKit +//! exposes [`accesskit::Action`], re-exported by GPUI as [`AccessibleAction`]. +//! Respond to actions with +//! [`div().on_a11y_action()`][StatefulInteractiveElement::on_a11y_action]. +//! +//! Some common actions are registered automatically. For example, +//! [`.on_click()`][StatefulInteractiveElement::on_click] adds an +//! [`AccessibleAction::Click`] handler that calls the click handler. +//! +//! ## Further reading +//! +//! - [AccessKit]: The cross-platform accessibility toolkit GPUI uses +//! internally. +//! - [MDN WAI-ARIA basics][mdn-aria]: Introduction to roles, properties, and +//! states. +//! - [ARIA Authoring Practices Guide][apg]: W3C patterns for accessible +//! widgets. +//! +//! [AccessKit]: https://accesskit.dev/ +//! [mdn-aria]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics +//! [apg]: https://www.w3.org/WAI/ARIA/apg/ + +#[cfg(doc)] +use crate::*; diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index a882507..146d7d6 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -141,6 +141,18 @@ impl Application { )) } + /// Builds an app with accessibility (AccessKit) integration forcibly + /// disabled. + /// + /// In this mode, accessibility APIs (for example + /// [`div().role()`][crate::StatefulInteractiveElement::role]) silently + /// no-op. + pub fn new_inaccessible(platform: Rc) -> Self { + let this = Self::with_platform(platform); + this.0.borrow_mut().accessibility_force_disabled = true; + this + } + /// Assigns the source of assets for the application. pub fn with_assets(self, asset_source: impl AssetSource) -> Self { let mut context_lock = self.0.borrow_mut(); @@ -656,6 +668,8 @@ pub struct App { pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, pub(crate) cursor_hide_mode: CursorHideMode, + /// Whether the app was created by [`Application::new_inaccessible`]. + pub(crate) accessibility_force_disabled: bool, flushing_effects: bool, pending_updates: usize, quit_mode: QuitMode, @@ -745,6 +759,7 @@ impl App { quit_mode: QuitMode::default(), quitting: false, cursor_hide_mode: CursorHideMode::default(), + accessibility_force_disabled: false, #[cfg(any(test, feature = "test-support", debug_assertions))] name: None, diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index 36822ae..cf3337f 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -366,7 +366,21 @@ impl AppContext for AsyncWindowContext { where T: 'static, { - self.app.new(build_entity) + let mut build_entity = Some(build_entity); + match self.app.update_window(self.window, |_, _, cx| { + cx.new( + build_entity + .take() + .expect("build_entity is taken exactly once"), + ) + }) { + Ok(entity) => entity, + Err(_) => self.app.new( + build_entity + .take() + .expect("update_window returned Err without invoking the closure"), + ), + } } fn reserve_entity(&mut self) -> Reservation { @@ -378,7 +392,19 @@ impl AppContext for AsyncWindowContext { reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> T, ) -> Entity { - self.app.insert_entity(reservation, build_entity) + let mut args = Some((reservation, build_entity)); + match self.app.update_window(self.window, |_, _, cx| { + let (reservation, build_entity) = args.take().expect("args are taken exactly once"); + cx.insert_entity(reservation, build_entity) + }) { + Ok(entity) => entity, + Err(_) => { + let (reservation, build_entity) = args + .take() + .expect("update_window returned Err without invoking the closure"); + self.app.insert_entity(reservation, build_entity) + } + } } fn update_entity( diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index 7b78e9b..4aa0307 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -103,6 +103,17 @@ pub trait Element: 'static + IntoElement { cx: &mut App, ); + /// Returns the accessible role for this element, if any. + /// + /// Elements that return `None` are not included in the accessibility tree. + /// Inclusion also requires a non-`None` [`id`][Element::id]. + fn a11y_role(&self) -> Option { + None + } + + /// Write accessibility properties to the given node. + fn write_a11y_info(&self, _node: &mut accesskit::Node) {} + /// Convert this element into a dynamically-typed [`AnyElement`]. fn into_any(self) -> AnyElement { AnyElement::new(self) @@ -431,6 +442,28 @@ impl Drawable { } let bounds = window.layout_bounds(layout_id); + let mut pushed_a11y_node = false; + if window.a11y.is_active() { + if let Some(global_id) = global_id.as_ref() { + if let Some(role) = self.element.a11y_role() { + let node_id = window.a11y.node_id_for(global_id); + let mut node = accesskit::Node::new(role); + let scale = window.scale_factor(); + node.set_bounds(accesskit::Rect { + x0: (bounds.origin.x.0 * scale) as f64, + y0: (bounds.origin.y.0 * scale) as f64, + x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64, + y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, + }); + self.element.write_a11y_info(&mut node); + pushed_a11y_node = window.a11y.nodes.push(node_id, node); + if pushed_a11y_node { + window.a11y.node_bounds.insert(node_id, bounds); + } + } + } + } + let node_id = window.next_frame.dispatch_tree.push_node(); let prepaint = self.element.prepaint( global_id.as_ref(), @@ -442,6 +475,10 @@ impl Drawable { ); window.next_frame.dispatch_tree.pop_node(); + if pushed_a11y_node { + window.a11y.nodes.pop(); + } + if global_id.is_some() { window.element_id_stack.pop(); } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 7080cc8..fe1ba5a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1120,6 +1120,118 @@ pub trait InteractiveElement: Sized { /// A trait for elements that want to use the standard GPUI interactivity features /// that require state. pub trait StatefulInteractiveElement: InteractiveElement { + /// Set the accessible role for this element. + fn role(mut self, role: accesskit::Role) -> Self { + debug_assert!( + role != accesskit::Role::GenericContainer, + "GenericContainer is filtered out of the a11y tree and has no effect" + ); + self.interactivity().override_role = Some(role); + self + } + + /// Set the accessible label for this element. + fn aria_label(mut self, label: impl Into) -> Self { + self.interactivity().aria_label = Some(label.into()); + self + } + + /// Set the selected state for this element. + fn aria_selected(mut self, selected: bool) -> Self { + self.interactivity().aria_selected = Some(selected); + self + } + + /// Set the expanded state for this element. + fn aria_expanded(mut self, expanded: bool) -> Self { + self.interactivity().aria_expanded = Some(expanded); + self + } + + /// Set the toggled state for this element. + fn aria_toggled(mut self, toggled: accesskit::Toggled) -> Self { + self.interactivity().aria_toggled = Some(toggled); + self + } + + /// Set the numeric value for this element. + fn aria_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_numeric_value = Some(value); + self + } + + /// Set the minimum numeric value for this element. + fn aria_min_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_min_numeric_value = Some(value); + self + } + + /// Set the maximum numeric value for this element. + fn aria_max_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_max_numeric_value = Some(value); + self + } + + /// Set the orientation of this element. + fn aria_orientation(mut self, orientation: accesskit::Orientation) -> Self { + self.interactivity().aria_orientation = Some(orientation); + self + } + + /// Set the heading level of this element. + fn aria_level(mut self, level: usize) -> Self { + self.interactivity().aria_level = Some(level); + self + } + + /// Set the position in set of this element. + fn aria_position_in_set(mut self, position: usize) -> Self { + self.interactivity().aria_position_in_set = Some(position); + self + } + + /// Set the size of set for this element. + fn aria_size_of_set(mut self, size: usize) -> Self { + self.interactivity().aria_size_of_set = Some(size); + self + } + + /// Set the row index for this element. + fn aria_row_index(mut self, index: usize) -> Self { + self.interactivity().aria_row_index = Some(index); + self + } + + /// Set the column index for this element. + fn aria_column_index(mut self, index: usize) -> Self { + self.interactivity().aria_column_index = Some(index); + self + } + + /// Set the row count for this element. + fn aria_row_count(mut self, count: usize) -> Self { + self.interactivity().aria_row_count = Some(count); + self + } + + /// Set the column count for this element. + fn aria_column_count(mut self, count: usize) -> Self { + self.interactivity().aria_column_count = Some(count); + self + } + + /// Register a handler for an accessibility action on this element. + fn on_a11y_action( + mut self, + action: accesskit::Action, + listener: impl FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity() + .a11y_action_listeners + .push((action, Box::new(listener))); + self + } + /// Set this element to focusable. fn focusable(mut self) -> Self { self.interactivity().focusable = true; @@ -1425,6 +1537,16 @@ impl Element for Div { self.interactivity.source_location() } + fn a11y_role(&self) -> Option { + self.interactivity + .override_role + .filter(|role| *role != accesskit::Role::GenericContainer) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + self.interactivity.write_a11y_info(node); + } + #[stacksafe] fn request_layout( &mut self, @@ -1622,6 +1744,7 @@ pub struct Interactivity { pub(crate) tracked_scroll_handle: Option, pub(crate) scroll_anchor: Option, pub(crate) scroll_offset: Option>>>, + pub(crate) current_a11y_node: bool, pub(crate) group: Option, /// The base style of the element, before any modifications are applied /// by focus, active, etc. @@ -1659,6 +1782,24 @@ pub struct Interactivity { pub(crate) tab_index: Option, pub(crate) tab_group: bool, pub(crate) tab_stop: bool, + pub(crate) a11y_action_listeners: + Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>, + pub(crate) override_role: Option, + pub(crate) aria_label: Option, + pub(crate) aria_selected: Option, + pub(crate) aria_expanded: Option, + pub(crate) aria_toggled: Option, + pub(crate) aria_numeric_value: Option, + pub(crate) aria_min_numeric_value: Option, + pub(crate) aria_max_numeric_value: Option, + pub(crate) aria_orientation: Option, + pub(crate) aria_level: Option, + pub(crate) aria_position_in_set: Option, + pub(crate) aria_size_of_set: Option, + pub(crate) aria_row_index: Option, + pub(crate) aria_column_index: Option, + pub(crate) aria_row_count: Option, + pub(crate) aria_column_count: Option, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -1778,9 +1919,31 @@ impl Interactivity { }, ); + let current_a11y_node_id = if window.a11y.is_active() { + global_id + .and_then(|global_id| window.a11y.node_id_for_existing(global_id)) + .filter(|node_id| window.a11y.nodes.has_current_node(*node_id)) + } else { + None + }; + let has_current_a11y_node = current_a11y_node_id.is_some(); + self.current_a11y_node = has_current_a11y_node; + if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { window.set_focus_handle(focus_handle, cx); + + if window.a11y.is_active() { + if let Some(node_id) = current_a11y_node_id { + if has_current_a11y_node { + window.a11y.focus_ids.insert(node_id, focus_handle.id); + if focus_handle.is_focused(window) { + window.a11y.nodes.set_focus(node_id); + } + } + } + } } + window.with_optional_element_state::( global_id, |element_state, window| { @@ -1809,6 +1972,31 @@ impl Interactivity { } let translated_bounds = bounds + style.translate; + let suppress_a11y_descendants = window.a11y.is_active() + && (style.visibility == Visibility::Hidden || style.display == Display::None); + if window.a11y.is_active() { + if suppress_a11y_descendants { + if let Some(node_id) = current_a11y_node_id { + if window.a11y.nodes.suppress_current_node(node_id) { + self.current_a11y_node = false; + window.a11y.node_bounds.remove(&node_id); + window.a11y.focus_ids.remove(&node_id); + window.a11y.action_listeners.remove(&node_id); + } + } + } else if let Some(node_id) = current_a11y_node_id { + let scale_factor = window.scale_factor(); + let updated_bounds = window.a11y.nodes.update_current_node_bounds( + node_id, + translated_bounds, + scale_factor, + ); + if updated_bounds { + window.a11y.node_bounds.insert(node_id, translated_bounds); + } + } + } + window.with_text_style(style.text_style().cloned(), |window| { window.with_content_mask( style.overflow_mask(translated_bounds, window.rem_size()), @@ -1821,7 +2009,13 @@ impl Interactivity { let scroll_offset = self.clamp_scroll_position(bounds, &style, window, cx); + if suppress_a11y_descendants { + window.a11y.nodes.begin_suppressing_descendants(); + } let result = f(&style, scroll_offset, hitbox, window, cx); + if suppress_a11y_descendants { + window.a11y.nodes.end_suppressing_descendants(); + } (result, element_state) }, ) @@ -2013,6 +2207,29 @@ impl Interactivity { } self.paint_keyboard_listeners(window, cx); + + if window.a11y.is_active() { + let current_a11y_node_id = if self.current_a11y_node + { + global_id.and_then(|global_id| { + window.a11y.node_id_for_existing(global_id) + }) + } else { + None + }; + if let Some(node_id) = current_a11y_node_id { + if !self.a11y_action_listeners.is_empty() { + for (action, listener) in + self.a11y_action_listeners.drain(..) + { + window.on_a11y_action( + node_id, action, listener, + ); + } + } + } + } + f(&style, window, cx); if let Some(_hitbox) = hitbox { @@ -2806,6 +3023,63 @@ impl Interactivity { style } + + pub(crate) fn write_a11y_info(&self, node: &mut accesskit::Node) { + if let Some(label) = &self.aria_label { + node.set_label(label.to_string()); + } + if let Some(selected) = self.aria_selected { + node.set_selected(selected); + } + if let Some(expanded) = self.aria_expanded { + node.set_expanded(expanded); + } + if let Some(toggled) = self.aria_toggled { + node.set_toggled(toggled); + } + if let Some(value) = self.aria_numeric_value { + node.set_numeric_value(value); + } + if let Some(value) = self.aria_min_numeric_value { + node.set_min_numeric_value(value); + } + if let Some(value) = self.aria_max_numeric_value { + node.set_max_numeric_value(value); + } + if let Some(orientation) = self.aria_orientation { + node.set_orientation(orientation); + } + if let Some(level) = self.aria_level { + node.set_level(level); + } + if let Some(position) = self.aria_position_in_set { + node.set_position_in_set(position); + } + if let Some(size) = self.aria_size_of_set { + node.set_size_of_set(size); + } + if let Some(index) = self.aria_row_index { + node.set_row_index(index); + } + if let Some(index) = self.aria_column_index { + node.set_column_index(index); + } + if let Some(count) = self.aria_row_count { + node.set_row_count(count); + } + if let Some(count) = self.aria_column_count { + node.set_column_count(count); + } + if !self.click_listeners.is_empty() { + node.add_action(accesskit::Action::Click); + } + if self.tracked_focus_handle.is_some() || self.focusable { + node.add_action(accesskit::Action::Focus); + } + for (action, _) in &self.a11y_action_listeners { + node.add_action(*action); + } + } } /// The per-frame state of an interactive element. Used for tracking stateful interactions like clicks @@ -3201,6 +3475,14 @@ where self.element.source_location() } + fn a11y_role(&self) -> Option { + self.element.a11y_role() + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + self.element.write_a11y_info(node); + } + fn request_layout( &mut self, id: Option<&GlobalElementId>, @@ -3517,6 +3799,184 @@ impl ScrollHandle { #[cfg(test)] mod tests { use super::*; + use crate::{ + AnyView, AppContext, Context, DrawPhase, Drawable, Render, StyleRefinement, TestAppContext, + deferred, text, + }; + use std::cell::Cell; + use std::rc::Rc; + use std::sync::Arc; + + fn draw_accessible( + cx: &mut crate::VisualTestContext, + origin: Point, + space: impl Into>, + f: impl FnOnce(&mut Window, &mut App) -> E, + ) -> accesskit::TreeUpdate + where + E: Element, + { + cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + window.a11y.begin_frame(); + + window.invalidator.set_phase(DrawPhase::Prepaint); + let mut element = Drawable::new(f(window, cx)); + element.layout_as_root(space.into(), window, cx); + window.with_absolute_element_offset(origin, |window| element.prepaint(window, cx)); + + window.invalidator.set_phase(DrawPhase::Paint); + element.paint(window, cx); + window.invalidator.set_phase(DrawPhase::None); + + let update = window.a11y.end_frame(); + window.a11y.set_active_for_test(false); + + window.next_frame.finish(&mut window.rendered_frame); + std::mem::swap(&mut window.rendered_frame, &mut window.next_frame); + window.next_frame.clear(); + + update + }) + } + + fn root_node(update: &accesskit::TreeUpdate) -> &accesskit::Node { + update + .nodes + .iter() + .find_map(|(node_id, node)| { + (*node_id == crate::window::a11y::ROOT_NODE_ID).then_some(node) + }) + .unwrap() + } + + fn node_with_role( + update: &accesskit::TreeUpdate, + role: accesskit::Role, + ) -> Option<(accesskit::NodeId, &accesskit::Node)> { + update + .nodes + .iter() + .find_map(|(node_id, node)| (node.role() == role).then_some((*node_id, node))) + } + + fn global_id(id: impl Into) -> GlobalElementId { + let ids: Arc<[ElementId]> = Arc::from([id.into()]); + GlobalElementId(ids) + } + + fn node_id_for_existing_element_id( + window: &Window, + id: impl Into, + ) -> Option { + window.a11y.node_id_for_existing(&global_id(id)) + } + + fn assert_tree_has_no_missing_children(update: &accesskit::TreeUpdate) { + let node_ids = update + .nodes + .iter() + .map(|(node_id, _)| *node_id) + .collect::>(); + + for (node_id, node) in &update.nodes { + for child_id in node.children() { + assert!( + node_ids.contains(child_id), + "node {node_id:?} references missing child {child_id:?}" + ); + } + } + } + + struct CachedDeferredRoot { + child: Entity, + } + + impl Render for CachedDeferredRoot { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + AnyView::from(self.child.clone()) + .cached(StyleRefinement::default().w(px(20.)).h(px(20.))) + } + } + + struct CachedDeferredChild { + action_count: Rc>, + } + + impl Render for CachedDeferredChild { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let action_count = self.action_count.clone(); + deferred( + div() + .id("cached-deferred-child") + .role(accesskit::Role::Button) + .on_a11y_action(accesskit::Action::Click, move |_, _, _| { + action_count.set(action_count.get() + 1); + }) + .w(px(10.)) + .h(px(10.)), + ) + } + } + + #[gpui::test] + fn a11y_cached_deferred_draw(cx: &mut TestAppContext) { + let action_count = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let action_count = action_count.clone(); + move |_, cx| { + let child = cx.new(|_| CachedDeferredChild { + action_count: action_count.clone(), + }); + CachedDeferredRoot { child } + } + }); + + cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + window.refresh(); + let _ = window.draw(cx); + }); + + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + + let child_id = cx.update(|window, _| { + assert_eq!( + window.a11y.action_listeners.len(), + 1, + "deferred child should rebuild its a11y action listener" + ); + let child_id = window + .a11y + .action_listeners + .keys() + .next() + .copied() + .expect("deferred child should have an a11y action listener"); + assert!( + window.a11y.node_bounds.contains_key(&child_id), + "deferred child should be emitted into the active a11y frame" + ); + child_id + }); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: child_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(action_count.get(), 1); + } #[test] fn key_context_accepts_try_into_errors_without_display() { @@ -3573,4 +4033,338 @@ mod tests { assert_eq!(handle.offset().y, px(-25.)); } + + #[gpui::test] + fn a11y_hidden_role_div_is_absent_from_tree(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let update = draw_accessible( + cx, + point(px(0.), px(0.)), + size(px(100.), px(100.)), + |_, _| { + div() + .id("hidden-button") + .role(accesskit::Role::Button) + .invisible() + .w(px(20.)) + .h(px(20.)) + }, + ); + + assert!(node_with_role(&update, accesskit::Role::Button).is_none()); + assert_eq!(root_node(&update).children(), &[]); + assert_tree_has_no_missing_children(&update); + } + + #[gpui::test] + fn a11y_display_none_role_div_is_absent_from_tree(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let focus_handle = cx.update(|_, cx| cx.focus_handle()); + + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let focus_handle = focus_handle.clone(); + move |_, _| { + div() + .id("display-none-button") + .role(accesskit::Role::Button) + .track_focus(&focus_handle) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}) + .hidden() + .child(text!(id = "display-none-label", "Hidden child")) + } + }); + + assert!(node_with_role(&update, accesskit::Role::Button).is_none()); + assert!(node_with_role(&update, accesskit::Role::Label).is_none()); + assert_eq!(root_node(&update).children(), &[]); + assert_tree_has_no_missing_children(&update); + + cx.update(|window, _| { + let node_id = node_id_for_existing_element_id(window, "display-none-button").unwrap(); + assert!(!window.a11y.node_bounds.contains_key(&node_id)); + assert!(!window.a11y.focus_ids.contains_key(&node_id)); + assert!(!window.a11y.action_listeners.contains_key(&node_id)); + }); + } + + #[gpui::test] + fn a11y_hidden_container_suppresses_descendants(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let focus_handle = cx.update(|_, cx| cx.focus_handle()); + + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let focus_handle = focus_handle.clone(); + move |_, _| { + div().invisible().child( + div() + .id("child-button") + .role(accesskit::Role::Button) + .track_focus(&focus_handle) + .child(text!(id = "child-label", "Hidden child")), + ) + } + }); + + assert!(node_with_role(&update, accesskit::Role::Button).is_none()); + assert!(node_with_role(&update, accesskit::Role::Label).is_none()); + assert_eq!(root_node(&update).children(), &[]); + assert_tree_has_no_missing_children(&update); + + cx.update(|window, _| { + let child_id = node_id_for_existing_element_id(window, "child-button").unwrap(); + assert!(!window.a11y.node_bounds.contains_key(&child_id)); + assert!(!window.a11y.focus_ids.contains_key(&child_id)); + }); + } + + #[gpui::test] + fn a11y_translated_clickable_div_updates_bounds_and_clicks_at_translated_center( + cx: &mut TestAppContext, + ) { + let clicked_at = Rc::new(Cell::new(None)); + let cx = cx.add_empty_window(); + + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let clicked_at = clicked_at.clone(); + move |_, _| { + div() + .id("translated-button") + .role(accesskit::Role::Button) + .on_click(move |event, _, _| clicked_at.set(Some(event.position()))) + .translate(px(10.), px(20.)) + .w(px(40.)) + .h(px(30.)) + } + }); + + let expected_bounds = Bounds::new(point(px(10.), px(20.)), size(px(40.), px(30.))); + let scale_factor = cx.update(|window, _| window.scale_factor()); + let (button_id, button) = node_with_role(&update, accesskit::Role::Button).unwrap(); + assert!(button.supports_action(accesskit::Action::Click)); + assert_eq!( + button.bounds(), + Some(accesskit::Rect { + x0: (expected_bounds.left().0 * scale_factor) as f64, + y0: (expected_bounds.top().0 * scale_factor) as f64, + x1: (expected_bounds.right().0 * scale_factor) as f64, + y1: (expected_bounds.bottom().0 * scale_factor) as f64, + }) + ); + + let node_bounds = cx + .update(|window, _| window.a11y.node_bounds.get(&button_id).copied()) + .unwrap(); + assert_eq!(node_bounds, expected_bounds); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: button_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(clicked_at.get(), Some(point(px(30.), px(35.)))); + } + + #[gpui::test] + fn a11y_window_transact_rolls_back_prepaint_state(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let accepted_global_id = global_id("accepted-node"); + let rejected_global_id = global_id("rejected-node"); + let accepted_bounds = Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))); + let rejected_bounds = Bounds::new(point(px(5.), px(6.)), size(px(7.), px(8.))); + + let (update, accepted_id, rejected_id, focus_ids, node_bounds) = cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + window.a11y.begin_frame(); + window.invalidator.set_phase(DrawPhase::Prepaint); + + let accepted_focus = cx.focus_handle(); + let rejected_focus = cx.focus_handle(); + let mut accepted_node = accesskit::Node::new(accesskit::Role::Button); + accepted_node.set_label("accepted"); + let accepted_id = window.a11y.node_id_for(&accepted_global_id); + assert!(window.a11y.nodes.push(accepted_id, accepted_node)); + window.a11y.node_bounds.insert(accepted_id, accepted_bounds); + window.a11y.focus_ids.insert(accepted_id, accepted_focus.id); + window.a11y.nodes.pop(); + + let mut rejected_id = None; + let result = window.transact(|window| { + let mut rejected_node = accesskit::Node::new(accesskit::Role::TextInput); + rejected_node.set_label("rejected"); + let node_id = window.a11y.node_id_for(&rejected_global_id); + rejected_id = Some(node_id); + assert!(window.a11y.nodes.push(node_id, rejected_node)); + window.a11y.node_bounds.insert(node_id, rejected_bounds); + window.a11y.focus_ids.insert(node_id, rejected_focus.id); + window.a11y.nodes.set_focus(node_id); + window.a11y.nodes.pop(); + Err::<(), ()>(()) + }); + assert!(result.is_err()); + + let focus_ids = window.a11y.focus_ids.clone(); + let node_bounds = window.a11y.node_bounds.clone(); + let update = window.a11y.end_frame(); + window.invalidator.set_phase(DrawPhase::None); + window.a11y.set_active_for_test(false); + ( + update, + accepted_id, + rejected_id.unwrap(), + focus_ids, + node_bounds, + ) + }); + + assert!(update.nodes.iter().any(|(id, _)| *id == accepted_id)); + assert!(!update.nodes.iter().any(|(id, _)| *id == rejected_id)); + assert_eq!(root_node(&update).children(), &[accepted_id]); + assert_eq!(update.focus, crate::window::a11y::ROOT_NODE_ID); + assert_eq!(node_bounds.get(&accepted_id), Some(&accepted_bounds)); + assert!(!node_bounds.contains_key(&rejected_id)); + assert!(focus_ids.contains_key(&accepted_id)); + assert!(!focus_ids.contains_key(&rejected_id)); + } + + #[cfg(not(debug_assertions))] + #[gpui::test] + fn a11y_duplicate_id_does_not_override_focus_fallback(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let first_focus_handle = cx.update(|_, cx| cx.focus_handle()); + let second_focus_handle = cx.update(|_, cx| cx.focus_handle()); + let first_focus_handle_id = first_focus_handle.id; + + draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let first_focus_handle = first_focus_handle; + let second_focus_handle = second_focus_handle; + move |_, _| { + div() + .child( + div() + .id("shared-focus") + .role(accesskit::Role::Button) + .track_focus(&first_focus_handle), + ) + .child( + div() + .id("shared-focus") + .role(accesskit::Role::TextInput) + .track_focus(&second_focus_handle), + ) + } + }); + + cx.update(|window, _| { + let duplicate_id = node_id_for_existing_element_id(window, "shared-focus").unwrap(); + assert_eq!( + window.a11y.focus_ids.get(&duplicate_id), + Some(&first_focus_handle_id) + ); + }); + } + + #[cfg(not(debug_assertions))] + #[gpui::test] + fn a11y_hidden_duplicate_id_does_not_remove_accepted_sibling_accessibility_state( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + let first_focus_handle = cx.update(|_, cx| cx.focus_handle()); + let second_focus_handle = cx.update(|_, cx| cx.focus_handle()); + let first_focus_handle_id = first_focus_handle.id; + let expected_bounds = Bounds::new(point(px(0.), px(0.)), size(px(20.), px(10.))); + + draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let first_focus_handle = first_focus_handle; + let second_focus_handle = second_focus_handle; + move |_, _| { + div() + .child( + div() + .id("shared-hidden-node") + .role(accesskit::Role::Button) + .track_focus(&first_focus_handle) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}) + .w(px(20.)) + .h(px(10.)), + ) + .child( + div() + .id("shared-hidden-node") + .role(accesskit::Role::Button) + .track_focus(&second_focus_handle) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}) + .invisible(), + ) + } + }); + + cx.update(|window, _| { + let duplicate_id = + node_id_for_existing_element_id(window, "shared-hidden-node").unwrap(); + assert_eq!( + window.a11y.node_bounds.get(&duplicate_id), + Some(&expected_bounds) + ); + assert_eq!( + window.a11y.focus_ids.get(&duplicate_id), + Some(&first_focus_handle_id) + ); + assert_eq!( + window + .a11y + .action_listeners + .get(&duplicate_id) + .expect("duplicate node should retain first listener") + .len(), + 1 + ); + }); + } + + #[cfg(not(debug_assertions))] + #[gpui::test] + fn a11y_duplicate_id_does_not_register_a11y_action_listeners_for_rejected_node( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + + draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + move |_, _| { + div() + .child( + div() + .id("shared-listener") + .role(accesskit::Role::Button) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}), + ) + .child( + div() + .id("shared-listener") + .role(accesskit::Role::TextInput) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}), + ) + } + }); + + cx.update(|window, _| { + let duplicate_id = node_id_for_existing_element_id(window, "shared-listener").unwrap(); + assert_eq!( + window + .a11y + .action_listeners + .get(&duplicate_id) + .map(|listeners| listeners.len()), + Some(1) + ); + }); + } } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index d4385c9..0de7939 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -739,6 +739,44 @@ impl ListState { pub fn viewport_bounds(&self) -> Bounds { self.0.borrow().last_layout_bounds.unwrap_or_default() } + + /// Returns whether the item is entirely above the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_above_viewport(&self, ix: usize) -> Option { + let viewport_bounds = self.viewport_bounds(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = self.logical_scroll_top(); + if ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(true); + } + + let item_bounds = self.bounds_for_item(ix)?; + Some(item_bounds.bottom() <= viewport_bounds.top()) + } + + /// Returns whether the item is entirely below the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_below_viewport(&self, ix: usize) -> Option { + let viewport_bounds = self.viewport_bounds(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = self.logical_scroll_top(); + if ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(false); + } + + let item_bounds = self.bounds_for_item(ix)?; + Some(item_bounds.top() >= viewport_bounds.bottom()) + } } impl StateInner { @@ -1640,6 +1678,114 @@ mod test { assert_eq!(offset.offset_in_item, px(0.)); } + struct TestListView(ListState); + impl Render for TestListView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(20.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + #[gpui::test] + fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) { + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + assert_eq!(state.item_is_above_viewport(0), None); + assert_eq!(state.item_is_below_viewport(0), None); + } + + #[gpui::test] + fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(1), Some(true)); + assert_eq!(state.item_is_below_viewport(1), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(2), Some(false)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(20.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(2), Some(true)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(3), Some(false)); + assert_eq!(state.item_is_below_viewport(3), Some(true)); + } + + #[gpui::test] + fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + state.scroll_to_end(); + + assert_eq!(state.logical_scroll_top().item_ix, state.item_count()); + assert_eq!(state.item_is_above_viewport(0), Some(true)); + assert_eq!(state.item_is_below_viewport(0), Some(false)); + } + #[gpui::test] fn test_measure_all_after_width_change(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/elements/retained_layer.rs b/crates/gpui/src/elements/retained_layer.rs index e02085e..80f22ab 100644 --- a/crates/gpui/src/elements/retained_layer.rs +++ b/crates/gpui/src/elements/retained_layer.rs @@ -178,6 +178,7 @@ impl Element for RetainedLayerElement { state.content_revision != content_revision || state.bounds != bounds || state.content_mask != content_mask + || window.a11y.is_active() } None => true, }; diff --git a/crates/gpui/src/elements/retained_layer_tests.rs b/crates/gpui/src/elements/retained_layer_tests.rs index 5b557d5..3316b70 100644 --- a/crates/gpui/src/elements/retained_layer_tests.rs +++ b/crates/gpui/src/elements/retained_layer_tests.rs @@ -1,8 +1,8 @@ -use std::{cell::Cell, rc::Rc}; +use std::{cell::Cell, rc::Rc, sync::Arc}; use crate::{ self as gpui, App, Bounds, Context, Element, ElementId, GlobalElementId, InspectorElementId, - IntoElement, LayoutId, Render, Style, TestAppContext, Window, fill, px, size, white, + IntoElement, LayoutId, Render, Style, TestAppContext, Window, fill, point, px, size, white, }; use super::*; @@ -77,6 +77,96 @@ impl Element for CountingElement { } } +struct A11yCountingElement { + paint_count: Rc>, + action_count: Rc>, +} + +impl IntoElement for A11yCountingElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for A11yCountingElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some("retained-child".into()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn a11y_role(&self) -> Option { + Some(accesskit::Role::Button) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + node.add_action(accesskit::Action::Click); + } + + fn request_layout( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + ( + window.request_layout( + Style { + size: size(px(10.).into(), px(10.).into()), + ..Default::default() + }, + [], + cx, + ), + (), + ) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _state: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) { + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + _cx: &mut App, + ) { + self.paint_count.set(self.paint_count.get() + 1); + window.paint_quad(fill(bounds, white())); + + if window.a11y.is_active() { + let node_id = window + .a11y + .node_id_for_existing(global_id.expect("a11y child must have a global id")) + .expect("a11y child should have a node id"); + let action_count = self.action_count.clone(); + window.on_a11y_action(node_id, accesskit::Action::Click, move |_, _, _| { + action_count.set(action_count.get() + 1); + }); + } + } +} + #[gpui::test] fn retained_layer_replays_child_paint_on_compositor_only_update(cx: &mut TestAppContext) { let paint_count = Rc::new(Cell::new(0)); @@ -109,6 +199,55 @@ fn retained_layer_replays_child_paint_on_compositor_only_update(cx: &mut TestApp assert_eq!(third_layer.content_revision, 1.into()); } +#[gpui::test] +fn retained_layer_a11y(cx: &mut TestAppContext) { + let paint_count = Rc::new(Cell::new(0)); + let action_count = Rc::new(Cell::new(0)); + let cx = cx.add_empty_window(); + + draw_a11y_retained_layer(cx, paint_count.clone(), action_count.clone(), 0, 1.0); + finish_frame(cx); + + assert_eq!(paint_count.get(), 1); + + draw_a11y_retained_layer(cx, paint_count.clone(), action_count.clone(), 0, 0.25); + finish_frame(cx); + + assert_eq!(paint_count.get(), 2); + + let child_id = cx + .update(|window, _| { + window + .a11y + .node_id_for_existing(&retained_child_global_id()) + }) + .expect("retained child should have an a11y node id"); + cx.update(|window, _| { + assert!( + window.a11y.node_bounds.contains_key(&child_id), + "retained child should be emitted into the active a11y frame" + ); + assert!( + window.a11y.action_listeners.contains_key(&child_id), + "retained child should rebuild its a11y action listener" + ); + }); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: child_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(action_count.get(), 1); +} + fn draw_retained_layer( cx: &mut crate::VisualTestContext, paint_count: Rc>, @@ -133,6 +272,46 @@ fn draw_retained_layer( }); } +fn draw_a11y_retained_layer( + cx: &mut crate::VisualTestContext, + paint_count: Rc>, + action_count: Rc>, + revision: u64, + opacity: f32, +) { + cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + window.a11y.begin_frame(); + + window.invalidator.set_phase(crate::DrawPhase::Prepaint); + let mut element = crate::Drawable::new( + A11yCountingElement { + paint_count, + action_count, + } + .with_retained_layer("layer", revision) + .opacity(opacity), + ); + element.layout_as_root(size(px(100.), px(100.)).into(), window, cx); + window.with_absolute_element_offset(point(px(0.), px(0.)), |window| { + element.prepaint(window, cx) + }); + + window.invalidator.set_phase(crate::DrawPhase::Paint); + element.paint(window, cx); + window.invalidator.set_phase(crate::DrawPhase::None); + + window.a11y.end_frame(); + }); +} + +fn retained_child_global_id() -> GlobalElementId { + GlobalElementId(Arc::from([ + ElementId::from("layer"), + ElementId::from("retained-child"), + ])) +} + fn finish_frame(cx: &mut crate::VisualTestContext) { cx.update(|window, _| { window.next_frame.finish(&mut window.rendered_frame); diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index e575a18..3bc5009 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -13,11 +13,175 @@ use std::{ borrow::Cow, cell::{Cell, RefCell}, mem, - ops::Range, + ops::{Deref, DerefMut, Range}, rc::Rc, sync::Arc, }; +/// An [`Element`] that renders accessible text. +#[derive(Debug, Clone)] +pub struct Text { + id: Option, + text: SharedString, +} + +impl Text { + /// Create a new [`Text`] element with a specific ID. + #[inline] + pub const fn new(id: ElementId, text: SharedString) -> Self { + Self { id: Some(id), text } + } + + /// Create a new [`Text`] element that is inaccessible to screen readers. + #[inline] + pub const fn new_inaccessible(text: SharedString) -> Self { + Self { id: None, text } + } + + /// The ID of this [`Text`] element. + #[inline] + pub const fn id(&self) -> Option<&ElementId> { + self.id.as_ref() + } + + /// Produce a new [`Text`] with the given `id`. + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// The text that this [`Text`] element will display. + #[inline] + pub const fn text(&self) -> &SharedString { + &self.text + } +} + +impl Deref for Text { + type Target = SharedString; + + fn deref(&self) -> &Self::Target { + &self.text + } +} + +impl DerefMut for Text { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.text + } +} + +/// Trivial hash function for the location information produced by the [`text`] +/// macro. Not covered by semver guarantees. +#[doc(hidden)] +pub const fn __hash_text_macro_location_unstable_do_not_use(s: &'static str) -> u64 { + const BASIS: u64 = 0xcbf29ce484222325; + const PRIME: u64 = 0x100000001b3; + + let bytes = s.as_bytes(); + let mut hash = BASIS; + let mut i = 0; + while i < bytes.len() { + hash ^= bytes[i] as u64; + hash = hash.wrapping_mul(PRIME); + i += 1; + } + hash +} + +/// Create a new accessible [`Text`] element. +#[macro_export] +macro_rules! text { + (id = $id:expr, $text:expr) => {{ $crate::Text::new($id.into(), $text.into()) }}; + ($text:expr) => {{ + const ID: &'static str = concat!(file!(), "/", line!(), ":", column!()); + const HASH: u64 = $crate::__hash_text_macro_location_unstable_do_not_use(ID); + $crate::Text::new($crate::ElementId::Integer(HASH), $text.into()) + }}; +} + +impl IntoElement for Text { + type Element = Self; + + #[inline] + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Text { + type RequestLayoutState = TextLayout; + type PrepaintState = (); + + fn id(&self) -> Option { + self.id.clone() + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn a11y_role(&self) -> Option { + self.id.is_some().then_some(accesskit::Role::Label) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + node.set_value(self.text.to_string()); + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + ::request_layout(&mut self.text, id, inspector_id, window, cx) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + ::prepaint( + &mut self.text, + id, + inspector_id, + bounds, + request_layout, + window, + cx, + ) + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + ::paint( + &mut self.text, + id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ); + } +} + impl Element for &'static str { type RequestLayoutState = TextLayout; type PrepaintState = (); @@ -394,14 +558,27 @@ impl TextLayout { } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let (text, runs) = if let Some(truncate_width) = truncate_width { - line_wrapper.truncate_line( - text.clone(), - truncate_width, - &truncation_affix, - &runs, - truncate_from, - ) + let (text, runs) = if truncate_width.is_some() { + if let Some(max_lines) = text_style.line_clamp + && let Some(wrap_width) = wrap_width + { + line_wrapper.truncate_wrapped_line( + text.clone(), + wrap_width, + max_lines, + &truncation_affix, + &runs, + truncate_from, + ) + } else { + line_wrapper.truncate_line( + text.clone(), + truncate_width.unwrap(), + &truncation_affix, + &runs, + truncate_from, + ) + } } else { (text.clone(), Cow::Borrowed(&*runs)) }; diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index c08d4f4..2a02e0b 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -13,9 +13,7 @@ use std::{ time::{Duration, Instant}, }; -pub use scheduler::{ - FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority, Task, -}; +pub use scheduler::{FallibleTask, LocalExecutor as SchedulerLocalExecutor, Priority, Task}; /// A pointer to the executor that is currently running, /// for spawning background tasks. @@ -29,7 +27,7 @@ pub struct BackgroundExecutor { /// for spawning tasks on the main thread. #[derive(Clone)] pub struct ForegroundExecutor { - inner: scheduler::ForegroundExecutor, + inner: scheduler::LocalExecutor, dispatcher: Arc, not_send: PhantomData>, } @@ -288,18 +286,29 @@ impl ForegroundExecutor { ) } else { let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); - let session_id = platform_scheduler.allocate_session_id(); - (platform_scheduler, session_id) + let inner = platform_scheduler.foreground_executor(); + return Self { + inner, + dispatcher, + not_send: PhantomData, + }; }; #[cfg(not(any(test, feature = "test-support")))] - let (scheduler, session_id): (Arc, _) = { + let inner = { let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); - let session_id = platform_scheduler.allocate_session_id(); - (platform_scheduler, session_id) + platform_scheduler.foreground_executor() }; - let inner = scheduler::ForegroundExecutor::new(session_id, scheduler); + #[cfg(any(test, feature = "test-support"))] + let inner = { + let scheduler_for_dispatch = Arc::downgrade(&scheduler); + scheduler::LocalExecutor::new(session_id, scheduler, move |runnable| { + if let Some(scheduler) = scheduler_for_dispatch.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) + }; Self { inner, @@ -374,7 +383,7 @@ impl ForegroundExecutor { } #[doc(hidden)] - pub fn scheduler_executor(&self) -> SchedulerForegroundExecutor { + pub fn scheduler_executor(&self) -> SchedulerLocalExecutor { self.inner.clone() } } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 7a8866c..b94fd71 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -53,6 +53,8 @@ mod util; mod view; mod window; +#[cfg(doc)] +pub mod _accessibility; #[cfg(doc)] pub mod _ownership_and_data_flow; @@ -72,6 +74,9 @@ mod seal { pub trait Sealed {} } +pub use accesskit; +pub use accesskit::Action as AccessibleAction; +pub use accesskit::{Orientation, Role, Toggled}; pub use action::*; pub use anyhow::Result; pub use app::*; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 142ce1d..36bb4a6 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -444,6 +444,16 @@ impl Tiling { } } +/// Callbacks for the accessibility adapter. +pub struct A11yCallbacks { + /// Called when the adapter is activated. + pub activation: Box Option + Send + 'static>, + /// Called when an accessibility action is requested. + pub action: Box, + /// Called when the adapter is deactivated. + pub deactivation: Box, +} + #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] #[expect(missing_docs)] pub struct RequestFrameOptions { @@ -557,6 +567,15 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn play_system_bell(&self) {} + /// Initialize the accessibility adapter with callbacks. + fn a11y_init(&self, _callbacks: A11yCallbacks) {} + + /// Provide a TreeUpdate to the accessibility adapter. + fn a11y_tree_update(&self, _tree_update: accesskit::TreeUpdate) {} + + /// Inform the adapter of updated window bounds. + fn a11y_update_window_bounds(&self) {} + #[cfg(any(test, feature = "test-support"))] fn as_test(&mut self) -> Option<&mut TestWindow> { None diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index 8a8479f..8bf77dc 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -134,8 +134,7 @@ impl PlatformDispatcher for TestDispatcher { } fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { - self.scheduler - .schedule_foreground(self.session_id, runnable); + self.scheduler.schedule_local(self.session_id, runnable); } fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) { diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index d056f47..aea2408 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -2,8 +2,12 @@ use crate::{PlatformDispatcher, RunnableMeta}; use async_task::Runnable; use chrono::{DateTime, Utc}; use futures::channel::oneshot; -use scheduler::{Clock, Priority, Scheduler, SessionId, TestScheduler, Timer}; +use scheduler::{ + Clock, LocalExecutor, Priority, Scheduler, SessionId, Task, TestScheduler, Timer, + spawn_dedicated_thread, +}; use std::{ + any::Any, future::Future, pin::Pin, sync::{ @@ -34,9 +38,19 @@ impl PlatformScheduler { } } - pub fn allocate_session_id(&self) -> SessionId { + fn next_session_id(&self) -> SessionId { SessionId::new(self.next_session_id.fetch_add(1, Ordering::SeqCst)) } + + pub fn foreground_executor(self: &Arc) -> LocalExecutor { + let session_id = self.next_session_id(); + let scheduler = Arc::downgrade(self); + LocalExecutor::new(session_id, self.clone(), move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) + } } impl Scheduler for PlatformScheduler { @@ -78,7 +92,7 @@ impl Scheduler for PlatformScheduler { } } - fn schedule_foreground(&self, _session_id: SessionId, runnable: Runnable) { + fn schedule_local(&self, _session_id: SessionId, runnable: Runnable) { self.dispatcher .dispatch_on_main_thread(runnable, Priority::default()); } @@ -102,7 +116,7 @@ impl Scheduler for PlatformScheduler { // Create a runnable that will send the completion signal let location = std::panic::Location::caller(); - let (runnable, _task) = async_task::Builder::new() + let (runnable, task) = async_task::Builder::new() .metadata(RunnableMeta { location }) .spawn( move |_| async move { @@ -112,6 +126,7 @@ impl Scheduler for PlatformScheduler { dispatcher.dispatch_after(duration, runnable); }, ); + task.detach(); runnable.schedule(); Timer::new(rx) @@ -121,6 +136,21 @@ impl Scheduler for PlatformScheduler { self.clock.clone() } + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + let session_id = self.next_session_id(); + spawn_dedicated_thread(session_id, self, move |executor| f(executor)) + } + fn as_test(&self) -> Option<&TestScheduler> { None } diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index e110478..2edb130 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -339,6 +339,7 @@ pub enum Primitive { impl Primitive { pub fn bounds(&self) -> &Bounds { match self { + Primitive::Shadow(shadow) if shadow.inset == 1 => &shadow.element_bounds, Primitive::Shadow(shadow) => &shadow.bounds, Primitive::BackdropBlur(blur) => &blur.bounds, Primitive::Quad(quad) => &quad.bounds, @@ -672,6 +673,11 @@ pub struct Shadow { pub corner_radii: Corners, pub content_mask: ContentMask, pub color: Hsla, + pub element_bounds: Bounds, + pub element_corner_radii: Corners, + /// 0 = drop shadow (rendered outside the element), 1 = inset shadow (rendered inside). + pub inset: u32, + pub pad: u32, // align to 8 bytes } impl From for Primitive { diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 7f8e66a..c5bb61a 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -357,6 +357,9 @@ pub struct BoxShadow { pub blur_radius: Pixels, /// How much should the shadow spread? pub spread_radius: Pixels, + /// Whether this is an inset shadow (drawn inside the element's bounds). + #[serde(default)] + pub inset: bool, } /// How to handle whitespace in text @@ -671,7 +674,7 @@ impl Style { .to_pixels(rem_size) .clamp_radii_for_quad_size(bounds.size); - window.paint_shadows(bounds, corner_radii, &self.box_shadow); + window.paint_drop_shadows(bounds, corner_radii, &self.box_shadow); if let Some(blur_radius) = self.backdrop_blur { window.paint_backdrop_blur(bounds, corner_radii, blur_radius); } @@ -703,6 +706,8 @@ impl Style { )); } + window.paint_inset_shadows(bounds, corner_radii, &self.box_shadow); + continuation(window, cx); if self.is_border_visible() { @@ -1492,4 +1497,33 @@ mod tests { style.text_style().unwrap().font_weight ); } + + #[test] + fn test_box_shadow_inset_deserialization_compatibility() { + let shadow = BoxShadow { + color: red(), + offset: Default::default(), + blur_radius: px(1.), + spread_radius: px(2.), + inset: true, + }; + + let legacy_json = { + let mut legacy = serde_json::to_value(&shadow).unwrap(); + legacy.as_object_mut().unwrap().remove("inset"); + legacy + }; + let legacy_shadow: BoxShadow = serde_json::from_value(legacy_json).unwrap(); + assert_eq!( + legacy_shadow, + BoxShadow { + inset: false, + ..shadow.clone() + } + ); + + let roundtripped_shadow: BoxShadow = + serde_json::from_str(&serde_json::to_string(&shadow).unwrap()).unwrap(); + assert_eq!(roundtripped_shadow, shadow); + } } diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index b62a0ad..08fed21 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -698,6 +698,28 @@ impl WindowTextSystem { layout } + /// Returns the shaped layout width of for the given character, in the given font and size. + pub fn layout_width(&self, font_id: FontId, font_size: Pixels, ch: char) -> Pixels { + let mut buffer = [0; 4]; + let buffer: &_ = ch.encode_utf8(&mut buffer); + self.line_layout_cache + .layout_line( + buffer, + font_size, + &[FontRun { + len: buffer.len(), + font_id, + }], + None, + ) + .width + } + + /// Returns the shaped layout width of an `em`. + pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { + self.layout_width(font_id, font_size, 'm') + } + /// Probe the line layout cache using a caller-provided content hash, without allocating. /// /// Returns `Some(layout)` if the layout is already cached in either the current frame diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index fc3da3b..c147e77 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -196,15 +196,7 @@ impl LineWrapper { if let Some(truncate_ix) = self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from) { - let result = match truncate_from { - TruncateFrom::Start => SharedString::from(format!( - "{truncation_affix}{}", - &line[line.ceil_char_boundary(truncate_ix + 1)..] - )), - TruncateFrom::End => { - SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix])) - } - }; + let result = truncated_line_text(&line, truncate_ix, truncation_affix, truncate_from); let mut runs = runs.to_vec(); update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); (result, Cow::Owned(runs)) @@ -213,6 +205,305 @@ impl LineWrapper { } } + /// Truncate text to fit within a given number of wrapped lines. + pub fn truncate_wrapped_line<'a>( + &mut self, + text: SharedString, + wrap_width: Pixels, + max_lines: usize, + truncation_affix: &str, + runs: &'a [TextRun], + truncate_from: TruncateFrom, + ) -> (SharedString, Cow<'a, [TextRun]>) { + if max_lines <= 1 { + return self.truncate_single_wrapped_line( + text, + wrap_width * max_lines, + truncation_affix, + runs, + truncate_from, + ); + } + + if truncate_from == TruncateFrom::Start { + return self.truncate_wrapped_line_start( + text, + wrap_width, + max_lines, + truncation_affix, + runs, + ); + } + + let affix_width = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width); + + let mut width = px(0.); + let mut line = 0; + let mut first_non_whitespace_ix = None; + let mut indent = None; + let mut last_candidate_ix = 0; + let mut last_candidate_width = px(0.); + let mut last_wrap_ix = 0; + let mut prev_c = '\0'; + let mut truncate_ix = 0; + + for (ix, c) in text.char_indices() { + if c == '\n' { + if line >= max_lines - 1 && !text[ix + 1..].trim().is_empty() { + let result = SharedString::from(format!( + "{}{truncation_affix}", + trim_end_before_truncation_affix(&text[..truncate_ix], truncation_affix) + )); + let mut runs = runs.to_vec(); + update_runs_after_truncation( + &result, + truncation_affix, + &mut runs, + TruncateFrom::End, + ); + return (result, Cow::Owned(runs)); + } + + line += 1; + width = px(0.); + first_non_whitespace_ix = None; + indent = None; + last_candidate_ix = 0; + last_candidate_width = px(0.); + last_wrap_ix = ix + 1; + prev_c = '\0'; + truncate_ix = ix + 1; + continue; + } + + let char_width = self.width_for_char(c); + + if Self::is_word_char(c) { + if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() { + last_candidate_ix = ix; + last_candidate_width = width; + } + } else if c != ' ' && first_non_whitespace_ix.is_some() { + last_candidate_ix = ix; + last_candidate_width = width; + } + + if c != ' ' && first_non_whitespace_ix.is_none() { + first_non_whitespace_ix = Some(ix); + } + + width += char_width; + + if line < max_lines - 1 { + if width > wrap_width && ix > last_wrap_ix { + if let (None, Some(first_non_whitespace_ix)) = (indent, first_non_whitespace_ix) + { + indent = Some( + Self::MAX_INDENT.min((first_non_whitespace_ix - last_wrap_ix) as u32), + ); + } + + if last_candidate_ix > 0 { + last_wrap_ix = last_candidate_ix; + width -= last_candidate_width; + last_candidate_ix = 0; + } else { + last_wrap_ix = ix; + width = char_width; + } + + if let Some(indent) = indent { + width += self.width_for_char(' ') * indent as f32; + } + + line += 1; + truncate_ix = last_wrap_ix; + } + } else { + if width + affix_width <= wrap_width { + truncate_ix = ix + c.len_utf8(); + } + + if width > wrap_width { + let result = SharedString::from(format!( + "{}{truncation_affix}", + trim_end_before_truncation_affix(&text[..truncate_ix], truncation_affix) + )); + let mut runs = runs.to_vec(); + update_runs_after_truncation( + &result, + truncation_affix, + &mut runs, + TruncateFrom::End, + ); + return (result, Cow::Owned(runs)); + } + } + + prev_c = c; + } + + (text, Cow::Borrowed(runs)) + } + + fn truncate_single_wrapped_line<'a>( + &mut self, + text: SharedString, + truncate_width: Pixels, + truncation_affix: &str, + runs: &'a [TextRun], + truncate_from: TruncateFrom, + ) -> (SharedString, Cow<'a, [TextRun]>) { + let Some(newline_ix) = text.find('\n') else { + return self.truncate_line(text, truncate_width, truncation_affix, runs, truncate_from); + }; + + let first_line = &text[..newline_ix]; + let (result, affix_inserted) = if !text[newline_ix + 1..].trim().is_empty() { + ( + self.truncate_line_with_forced_affix( + first_line, + truncate_width, + truncation_affix, + truncate_from, + ), + !truncation_affix.is_empty(), + ) + } else if let Some(truncate_ix) = + self.should_truncate_line(first_line, truncate_width, truncation_affix, truncate_from) + { + ( + truncated_line_text(first_line, truncate_ix, truncation_affix, truncate_from), + !truncation_affix.is_empty(), + ) + } else { + (SharedString::from(first_line), false) + }; + + let mut result_runs = runs_for_prefix(runs, newline_ix); + if affix_inserted || result.len() < first_line.len() { + let affix = if affix_inserted { truncation_affix } else { "" }; + update_runs_after_truncation(&result, affix, &mut result_runs, truncate_from); + result_runs.retain(|run| run.len > 0); + } + + if result_runs.is_empty() + && !result.is_empty() + && let Some(mut run) = runs.first().cloned() + { + run.len = result.len(); + result_runs.push(run); + } + + (result, Cow::Owned(result_runs)) + } + + fn truncate_line_with_forced_affix( + &mut self, + line: &str, + truncate_width: Pixels, + truncation_affix: &str, + truncate_from: TruncateFrom, + ) -> SharedString { + if truncation_affix.is_empty() { + if let Some(truncate_ix) = + self.should_truncate_line(line, truncate_width, truncation_affix, truncate_from) + { + truncated_line_text(line, truncate_ix, truncation_affix, truncate_from) + } else { + SharedString::from(line) + } + } else { + let affix_width = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width); + + match truncate_from { + TruncateFrom::Start => { + let mut width = px(0.); + let mut truncate_ix = line.len(); + for (ix, c) in line.char_indices().rev() { + let char_width = self.width_for_char(c); + if width + char_width + affix_width <= truncate_width { + width += char_width; + truncate_ix = ix; + } else { + break; + } + } + SharedString::from(format!("{truncation_affix}{}", &line[truncate_ix..])) + } + TruncateFrom::End => { + let mut width = px(0.); + let mut truncate_ix = 0; + for (ix, c) in line.char_indices() { + let char_width = self.width_for_char(c); + if width + char_width + affix_width <= truncate_width { + width += char_width; + truncate_ix = ix + c.len_utf8(); + } else { + break; + } + } + SharedString::from(format!( + "{}{truncation_affix}", + trim_end_before_truncation_affix(&line[..truncate_ix], truncation_affix) + )) + } + } + } + } + + fn truncate_wrapped_line_start<'a>( + &mut self, + text: SharedString, + wrap_width: Pixels, + max_lines: usize, + truncation_affix: &str, + runs: &'a [TextRun], + ) -> (SharedString, Cow<'a, [TextRun]>) { + if self.wrapped_line_count(&text, wrap_width) <= max_lines { + return (text, Cow::Borrowed(runs)); + } + + let mut candidates = text.char_indices().map(|(ix, _)| ix).collect::>(); + candidates.push(text.len()); + + let mut low = 0; + let mut high = candidates.len() - 1; + while low < high { + let mid = (low + high) / 2; + let candidate = candidates[mid]; + let result = format!("{truncation_affix}{}", &text[candidate..]); + + if self.wrapped_line_count(&result, wrap_width) <= max_lines { + high = mid; + } else { + low = mid + 1; + } + } + + let result = SharedString::from(format!("{truncation_affix}{}", &text[candidates[low]..])); + let mut runs = runs.to_vec(); + update_runs_after_truncation(&result, truncation_affix, &mut runs, TruncateFrom::Start); + runs.retain(|run| run.len > 0); + (result, Cow::Owned(runs)) + } + + fn wrapped_line_count(&mut self, text: &str, wrap_width: Pixels) -> usize { + text.split('\n') + .map(|line| { + self.wrap_line(&[LineFragment::text(line)], wrap_width) + .count() + + 1 + }) + .sum() + } + /// Any character in this list should be treated as a word character, /// meaning it can be part of a word that should not be wrapped. pub(crate) fn is_word_char(c: char) -> bool { @@ -272,6 +563,55 @@ impl LineWrapper { } } +fn truncated_line_text( + line: &str, + truncate_ix: usize, + truncation_affix: &str, + truncate_from: TruncateFrom, +) -> SharedString { + match truncate_from { + TruncateFrom::Start => SharedString::from(format!( + "{truncation_affix}{}", + &line[line.ceil_char_boundary(truncate_ix + 1)..] + )), + TruncateFrom::End => SharedString::from(format!( + "{}{truncation_affix}", + trim_end_before_truncation_affix(&line[..truncate_ix], truncation_affix) + )), + } +} + +fn trim_end_before_truncation_affix<'a>(text: &'a str, truncation_affix: &str) -> &'a str { + if truncation_affix.is_empty() { + text + } else { + text.trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation()) + } +} + +fn runs_for_prefix(runs: &[TextRun], prefix_len: usize) -> Vec { + let mut prefix_runs = Vec::new(); + let mut remaining_len = prefix_len; + + for run in runs { + if remaining_len == 0 { + break; + } + + let mut run = run.clone(); + if run.len > remaining_len { + run.len = remaining_len; + prefix_runs.push(run); + break; + } + + remaining_len -= run.len; + prefix_runs.push(run); + } + + prefix_runs +} + fn update_runs_after_truncation( result: &str, ellipsis: &str, @@ -281,26 +621,38 @@ fn update_runs_after_truncation( let mut truncate_at = result.len() - ellipsis.len(); match truncate_from { TruncateFrom::Start => { + let mut first_retained_run_ix = None; for (run_index, run) in runs.iter_mut().enumerate().rev() { if run.len <= truncate_at { truncate_at -= run.len; + first_retained_run_ix = Some(run_index); } else { run.len = truncate_at + ellipsis.len(); runs.splice(..run_index, std::iter::empty()); - break; + return; } } + if let Some(run_index) = first_retained_run_ix { + runs[run_index].len += ellipsis.len(); + runs.splice(..run_index, std::iter::empty()); + } } TruncateFrom::End => { + let mut last_retained_run_ix = None; for (run_index, run) in runs.iter_mut().enumerate() { if run.len <= truncate_at { truncate_at -= run.len; + last_retained_run_ix = Some(run_index); } else { run.len = truncate_at + ellipsis.len(); runs.truncate(run_index + 1); - break; + return; } } + if let Some(run_index) = last_retained_run_ix { + runs[run_index].len += ellipsis.len(); + runs.truncate(run_index + 1); + } } } } @@ -410,6 +762,17 @@ mod tests { .collect() } + fn wrapped_line_count(wrapper: &mut LineWrapper, text: &str, wrap_width: Pixels) -> usize { + text.split('\n') + .map(|line| { + wrapper + .wrap_line(&[LineFragment::text(line)], wrap_width) + .count() + + 1 + }) + .sum() + } + #[test] fn test_wrap_line() { let mut wrapper = build_wrapper(); @@ -557,12 +920,13 @@ mod tests { text: &'static str, expected: &'static str, ellipsis: &str, + line_width: Pixels, ) { let dummy_run_lens = vec![text.len()]; let dummy_runs = generate_test_runs(&dummy_run_lens); let (result, dummy_runs) = wrapper.truncate_line( text.into(), - px(220.), + line_width, ellipsis, &dummy_runs, TruncateFrom::End, @@ -576,25 +940,32 @@ mod tests { "aa bbb cccc ddddd eeee ffff gggg", "aa bbb cccc ddddd eeee", "", + px(220.), ); perform_test( &mut wrapper, "aa bbb cccc ddddd eeee ffff gggg", "aa bbb cccc ddddd eee…", "…", + px(220.), ); perform_test( &mut wrapper, "aa bbb cccc ddddd eeee ffff gggg", "aa bbb cccc dddd......", "......", + px(220.), ); perform_test( &mut wrapper, "aa bbb cccc 🦀🦀🦀🦀🦀 eeee ffff gggg", "aa bbb cccc 🦀🦀🦀🦀…", "…", + px(220.), ); + perform_test(&mut wrapper, "hello world", "hello…", "…", px(70.)); + perform_test(&mut wrapper, "hello, world", "hello…", "…", px(70.)); + perform_test(&mut wrapper, "hello, world", "hello, ", "", px(70.)); } #[test] @@ -805,6 +1176,220 @@ mod tests { perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]); } + #[test] + fn test_multiline_truncation_fits_within_wrapped_lines() { + let mut wrapper = build_wrapper(); + let text = "aa bbbbbb cccccc dddddd eeee ffff"; + let wrap_width = px(72.); + let max_lines = 2; + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "…", + &runs, + TruncateFrom::End, + ); + + let wrapped_line_count = wrapper + .wrap_line(&[LineFragment::text(&truncated)], wrap_width) + .count() + + 1; + assert!( + wrapped_line_count <= max_lines, + "{truncated:?} wrapped into {wrapped_line_count} lines" + ); + assert!(truncated.ends_with('…')); + } + + #[test] + fn test_multiline_truncation_no_truncation_needed() { + let mut wrapper = build_wrapper(); + let text = "aa bbb cccccc"; + let runs = generate_test_runs(&[text.len()]); + + let (result, _) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::End); + + assert_eq!(result, text); + } + + #[test] + fn test_multiline_truncation_three_lines() { + let mut wrapper = build_wrapper(); + let text = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj"; + let wrap_width = px(72.); + let max_lines = 3; + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "…", + &runs, + TruncateFrom::End, + ); + + let wrapped_line_count = wrapper + .wrap_line(&[LineFragment::text(&truncated)], wrap_width) + .count() + + 1; + assert!( + wrapped_line_count <= max_lines, + "{truncated:?} wrapped into {wrapped_line_count} lines" + ); + assert!(truncated.ends_with('…')); + } + + #[test] + fn test_multiline_truncation_with_newlines() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld foo bar baz"; + let wrap_width = px(72.); + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + 2, + "…", + &runs, + TruncateFrom::End, + ); + + let mut lines = truncated.splitn(2, '\n'); + assert_eq!(lines.next(), Some("hello")); + let second_line = lines.next().expect("newline should be preserved"); + let second_line_width = second_line + .chars() + .map(|c| wrapper.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width); + assert!(second_line_width <= wrap_width); + assert!(truncated.ends_with('…')); + } + + #[test] + fn test_multiline_truncation_newline_on_last_line() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld\nmore"; + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::End); + + assert_eq!(truncated.split('\n').next(), Some("hello")); + assert!(truncated.ends_with('…')); + } + + #[test] + fn test_multiline_truncation_trailing_newline() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld\n"; + let runs = generate_test_runs(&[text.len()]); + + let (result, _) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::End); + + assert_eq!(result, text); + } + + #[test] + fn test_multiline_start_truncation_fits_within_wrapped_lines() { + let mut wrapper = build_wrapper(); + let text = "aa bbbbbb cccccc dddddd eeee ffff"; + let wrap_width = px(72.); + let max_lines = 2; + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "…", + &runs, + TruncateFrom::Start, + ); + + assert!(truncated.starts_with('…')); + let line_count = wrapped_line_count(&mut wrapper, &truncated, wrap_width); + assert!( + line_count <= max_lines, + "{truncated:?} wrapped into {line_count} lines" + ); + } + + #[test] + fn test_multiline_start_truncation_no_truncation_needed() { + let mut wrapper = build_wrapper(); + let text = "aa bbb cccccc"; + let runs = generate_test_runs(&[text.len()]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::Start); + + assert_eq!(result, text); + assert!(matches!(result_runs, Cow::Borrowed(_))); + } + + #[test] + fn test_multiline_start_truncation_updates_runs() { + let mut wrapper = build_wrapper(); + let text = "abcdefghijklmnopqrst"; + let runs = generate_test_runs(&[5, 5, 5, 5]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::Start); + + assert_eq!(result, "…hijklmnopqrst"); + let result_runs = result_runs.into_owned(); + assert_eq!( + result_runs.iter().map(|run| run.len).collect::>(), + vec![6, 5, 5] + ); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + assert!(result_runs.iter().all(|run| run.len > 0)); + } + + #[test] + fn test_one_line_wrapped_truncation_stops_at_hard_newline() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld"; + let runs = generate_test_runs(&[5, 1, 5]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(500.), 1, "…", &runs, TruncateFrom::End); + + assert_eq!(result, "hello…"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + assert!(result_runs.iter().all(|run| run.len > 0)); + } + + #[test] + fn test_one_line_wrapped_truncation_leading_newline_covers_affix_run() { + let mut wrapper = build_wrapper(); + let text = "\nworld"; + let runs = generate_test_runs(&[1, 5]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(500.), 1, "…", &runs, TruncateFrom::End); + + assert_eq!(result, "…"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + assert!(result_runs.iter().all(|run| run.len > 0)); + } + #[test] fn test_is_word_char() { #[track_caller] diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 39b87db..5c1d660 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -158,6 +158,7 @@ impl Element for AnyView { && element_state.cache_key.text_style == text_style && !window.dirty_views.contains(&self.entity_id()) && !window.refreshing + && !window.a11y.is_active() { let prepaint_start = window.prepaint_index(); window.reuse_prepaint(element_state.prepaint_range.clone()); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 60c1ce9..45fa79c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -49,14 +49,18 @@ use std::{ rc::Rc, sync::{ Arc, Weak, - atomic::{AtomicUsize, Ordering::SeqCst}, + atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst}, }, time::{Duration, Instant}, }; use uuid::Uuid; +pub(crate) mod a11y; mod prompts; +use self::a11y::A11y; +#[cfg(not(target_family = "wasm"))] +use self::a11y::ROOT_NODE_ID; use crate::util::atomic_incr_if_not_zero; pub use prompts::*; @@ -1031,6 +1035,7 @@ pub struct Window { captured_hitbox: Option, #[cfg(any(feature = "inspector", debug_assertions))] inspector: Option>, + pub(crate) a11y: A11y, } #[derive(Clone, Debug, Default)] @@ -1251,6 +1256,81 @@ impl Window { WindowBounds::Windowed(_) => {} } + let accessibility_force_disabled = cx.accessibility_force_disabled; + let a11y_active_flag = Arc::new(AtomicBool::new(false)); + + #[cfg(not(target_family = "wasm"))] + if !accessibility_force_disabled { + let initial_tree = accesskit::TreeUpdate { + nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))], + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: ROOT_NODE_ID, + }; + let (activation_sender, activation_receiver) = async_channel::unbounded::<()>(); + let (deactivation_sender, deactivation_receiver) = async_channel::unbounded::<()>(); + let (action_sender, action_receiver) = + async_channel::unbounded::(); + + platform_window.a11y_init(crate::A11yCallbacks { + activation: { + let active_flag = a11y_active_flag.clone(); + Box::new(move || { + log::info!("Accessibility activated"); + active_flag.store(true, SeqCst); + activation_sender.send_blocking(()).log_err(); + Some(initial_tree.clone()) + }) + }, + action: Box::new(move |request| { + action_sender.send_blocking(request).log_err(); + }), + deactivation: { + let active_flag = a11y_active_flag.clone(); + Box::new(move || { + log::info!("Accessibility deactivated"); + active_flag.store(false, SeqCst); + deactivation_sender.send_blocking(()).log_err(); + }) + }, + }); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while activation_receiver.recv().await.is_ok() { + handle + .update(&mut async_cx, |_, window, _| window.refresh()) + .log_err(); + } + }) + .detach(); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while deactivation_receiver.recv().await.is_ok() { + handle + .update(&mut async_cx, |_, window, _| window.refresh()) + .log_err(); + } + }) + .detach(); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while let Ok(request) = action_receiver.recv().await { + handle + .update(&mut async_cx, |_, window, cx| { + window.handle_a11y_action(request, cx); + }) + .log_err(); + } + }) + .detach(); + } + platform_window.on_close(Box::new({ let window_id = handle.window_id(); let mut cx = cx.to_async(); @@ -1542,6 +1622,7 @@ impl Window { captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, + a11y: A11y::new(a11y_active_flag, accessibility_force_disabled), }) } @@ -2472,6 +2553,11 @@ impl Window { self.invalidator.set_phase(DrawPhase::Prepaint); self.tooltip_bounds.take(); + self.a11y.sync_active_flag(); + if self.a11y.is_active() { + self.a11y.begin_frame(); + } + let _inspector_width: Pixels = rems(30.0).to_pixels(self.rem_size()); let root_size = { #[cfg(any(feature = "inspector", debug_assertions))] @@ -2538,6 +2624,22 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] self.paint_inspector_hitbox(cx); + + let a11y_active_start_of_frame = self.a11y.is_active(); + self.a11y.sync_active_flag(); + let a11y_active_end_of_frame = self.a11y.is_active(); + + if a11y_active_start_of_frame { + let tree_update = self.a11y.end_frame(); + + if a11y_active_end_of_frame { + log::debug!( + "Sending a11y tree update: {} nodes", + tree_update.nodes.len() + ); + self.platform_window.a11y_tree_update(tree_update); + } + } } fn prepaint_tooltip(&mut self, cx: &mut App) -> Option { @@ -2653,7 +2755,13 @@ impl Window { }); }) } else { - self.reuse_prepaint(deferred_draw.prepaint_range.clone()); + debug_assert!( + !self.a11y.is_active(), + "cached deferred prepaint reuse reached while a11y is active" + ); + if !self.a11y.is_active() { + self.reuse_prepaint(deferred_draw.prepaint_range.clone()); + } } let prepaint_end = self.prepaint_index(); deferred_draw.prepaint_range = prepaint_start..prepaint_end; @@ -2697,7 +2805,13 @@ impl Window { }) }) } else { - self.reuse_paint(deferred_draw.paint_range.clone()); + debug_assert!( + !self.a11y.is_active(), + "cached deferred paint reuse reached while a11y is active" + ); + if !self.a11y.is_active() { + self.reuse_paint(deferred_draw.paint_range.clone()); + } } let paint_end = self.paint_index(); deferred_draw.paint_range = paint_start..paint_end; @@ -2958,6 +3072,7 @@ impl Window { pub fn transact(&mut self, f: impl FnOnce(&mut Self) -> Result) -> Result { self.invalidator.debug_assert_prepaint(); let index = self.prepaint_index(); + let a11y_snapshot = self.a11y.is_active().then(|| self.a11y.prepaint_snapshot()); let result = f(self); if result.is_err() { self.next_frame.hitboxes.truncate(index.hitboxes_index); @@ -2974,6 +3089,9 @@ impl Window { .accessed_element_states .truncate(index.accessed_element_states_index); self.text_system.truncate_layouts(index.line_layout_index); + if let Some(a11y_snapshot) = a11y_snapshot { + self.a11y.restore_prepaint_snapshot(a11y_snapshot); + } } result } @@ -3296,10 +3414,12 @@ impl Window { result } - /// Paint one or more drop shadows into the scene for the next frame at the current z-index. + /// Paint the drop (non-inset) shadows from `shadows` into the scene at the current + /// z-index. Inset shadows are skipped; paint those with [`Self::paint_inset_shadows`] + /// after the element's background so they layer on top of the fill. /// /// This method should only be called as part of the paint phase of element drawing. - pub fn paint_shadows( + pub fn paint_drop_shadows( &mut self, bounds: Bounds, corner_radii: Corners, @@ -3310,7 +3430,12 @@ impl Window { let scale_factor = self.scale_factor(); let content_mask = self.content_mask(); let opacity = self.element_opacity(); + let element_bounds = bounds.scale(scale_factor); + let element_corner_radii = corner_radii.scale(scale_factor); for shadow in shadows { + if shadow.inset { + continue; + } let shadow_bounds = (bounds + shadow.offset).dilate(shadow.spread_radius); self.next_frame.scene.insert_primitive(Shadow { order: 0, @@ -3319,6 +3444,53 @@ impl Window { content_mask: content_mask.scale(scale_factor), corner_radii: corner_radii.scale(scale_factor), color: shadow.color.opacity(opacity), + element_bounds, + element_corner_radii, + inset: 0, + pad: 0, + }); + } + } + + /// Paint the inset shadows from `shadows` into the scene at the current z-index. Should + /// be called after the element's background so the shadow layers on top of the fill. + /// Drop shadows are skipped; paint those with [`Self::paint_drop_shadows`] before the background. + pub fn paint_inset_shadows( + &mut self, + bounds: Bounds, + corner_radii: Corners, + shadows: &[BoxShadow], + ) { + self.invalidator.debug_assert_paint(); + + let scale_factor = self.scale_factor(); + let content_mask = self.content_mask(); + let opacity = self.element_opacity(); + let element_bounds = bounds.scale(scale_factor); + let element_corner_radii = corner_radii.scale(scale_factor); + for shadow in shadows { + if !shadow.inset { + continue; + } + let hole = (bounds + shadow.offset).dilate(-shadow.spread_radius); + let zero = Pixels::ZERO; + let hole_corner_radii = Corners { + top_left: (corner_radii.top_left - shadow.spread_radius).max(zero), + top_right: (corner_radii.top_right - shadow.spread_radius).max(zero), + bottom_right: (corner_radii.bottom_right - shadow.spread_radius).max(zero), + bottom_left: (corner_radii.bottom_left - shadow.spread_radius).max(zero), + }; + self.next_frame.scene.insert_primitive(Shadow { + order: 0, + blur_radius: shadow.blur_radius.scale(scale_factor), + bounds: hole.scale(scale_factor), + content_mask: content_mask.scale(scale_factor), + corner_radii: hole_corner_radii.scale(scale_factor), + color: shadow.color.opacity(opacity), + element_bounds, + element_corner_radii, + inset: 1, + pad: 0, }); } } @@ -5203,6 +5375,80 @@ impl Window { self.platform_window.play_system_bell() } + /// Register a listener for an accessibility action on a specific node. + pub fn on_a11y_action( + &mut self, + node_id: accesskit::NodeId, + action: accesskit::Action, + listener: impl FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static, + ) { + self.a11y + .action_listeners + .entry(node_id) + .or_default() + .push((action, Box::new(listener))); + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn handle_a11y_action(&mut self, request: accesskit::ActionRequest, cx: &mut App) { + if let Some(mut listeners) = self.a11y.action_listeners.remove(&request.target_node) { + let extra_data = request.data.as_ref(); + let mut matched = false; + for (action, listener) in &mut listeners { + if *action == request.action { + listener(extra_data, self, cx); + matched = true; + } + } + self.a11y + .action_listeners + .insert(request.target_node, listeners); + if matched { + return; + } + } + + match request.action { + accesskit::Action::Click => { + if let Some(bounds) = self.a11y.node_bounds.get(&request.target_node).copied() { + let center = bounds.center(); + let mouse_down = PlatformInput::MouseDown(crate::MouseDownEvent { + button: MouseButton::Left, + position: center, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + let mouse_up = PlatformInput::MouseUp(MouseUpEvent { + button: MouseButton::Left, + position: center, + modifiers: Modifiers::default(), + click_count: 1, + }); + self.dispatch_event(mouse_down, cx); + self.dispatch_event(mouse_up, cx); + } + } + accesskit::Action::Focus => { + if let Some(focus_id) = self.a11y.focus_ids.get(&request.target_node).copied() + && let Some(handle) = FocusHandle::for_id(focus_id, &cx.focus_handles) + { + self.focus(&handle, cx); + } + } + accesskit::Action::Blur => { + self.blur(); + } + _ => { + log::debug!( + "Unhandled a11y action: {:?} on {:?}", + request.action, + request.target_node + ); + } + } + } + /// Toggles the inspector mode on this window. #[cfg(any(feature = "inspector", debug_assertions))] pub fn toggle_inspector(&mut self, cx: &mut App) { diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs new file mode 100644 index 0000000..ab124c2 --- /dev/null +++ b/crates/gpui/src/window/a11y.rs @@ -0,0 +1,708 @@ +//! Accessibility support, provided by [AccessKit][accesskit]. +//! +//! User-facing guide-level docs live in [`crate::_accessibility`]. + +use crate::{App, Bounds, FocusId, GlobalElementId, Pixels, Window}; +use accesskit::{Action, NodeId, TreeUpdate}; +use collections::{FxHashMap, FxHashSet}; +use smallvec::SmallVec; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +/// The fixed AccessKit node ID used for the root of every window's a11y tree. +pub(crate) const ROOT_NODE_ID: NodeId = NodeId(0); + +/// A listener for an accessibility action on a specific node. +pub(crate) type A11yActionListener = + Box, &mut Window, &mut App) + 'static>; + +/// Per-window accessibility state. +pub(crate) struct A11y { + force_disabled: bool, + active_flag: Arc, + active_this_frame: bool, + node_ids: FxHashMap, + next_node_id: u64, + pub(crate) nodes: A11yNodeBuilder, + pub(crate) focus_ids: FxHashMap, + pub(crate) node_bounds: FxHashMap>, + pub(crate) action_listeners: FxHashMap>, +} + +impl A11y { + pub(crate) fn new(active_flag: Arc, force_disabled: bool) -> Self { + Self { + force_disabled, + active_flag, + active_this_frame: false, + node_ids: FxHashMap::default(), + next_node_id: ROOT_NODE_ID.0 + 1, + nodes: A11yNodeBuilder::new(), + focus_ids: FxHashMap::default(), + node_bounds: FxHashMap::default(), + action_listeners: FxHashMap::default(), + } + } + + pub(crate) fn sync_active_flag(&mut self) { + self.active_this_frame = !self.force_disabled && self.active_flag.load(Ordering::SeqCst); + } + + pub(crate) fn is_active(&self) -> bool { + self.active_this_frame + } + + /// Force accessibility active/inactive for tests without going through a platform adapter. + #[cfg(test)] + pub(crate) fn set_active_for_test(&mut self, active: bool) { + self.active_flag.store(active, Ordering::SeqCst); + self.sync_active_flag(); + } + + pub(crate) fn begin_frame(&mut self) { + self.focus_ids.clear(); + self.node_bounds.clear(); + self.action_listeners.clear(); + self.nodes.begin_frame(); + } + + pub(crate) fn node_id_for(&mut self, global_id: &GlobalElementId) -> NodeId { + if let Some(node_id) = self.node_ids.get(global_id) { + return *node_id; + } + + let node_id = NodeId(self.next_node_id); + debug_assert_ne!(node_id, ROOT_NODE_ID); + self.next_node_id += 1; + self.node_ids.insert(global_id.clone(), node_id); + node_id + } + + pub(crate) fn node_id_for_existing(&self, global_id: &GlobalElementId) -> Option { + self.node_ids.get(global_id).copied() + } + + pub(crate) fn end_frame(&mut self) -> TreeUpdate { + self.nodes.finalize() + } + + pub(crate) fn prepaint_snapshot(&self) -> A11yPrepaintSnapshot { + A11yPrepaintSnapshot { + nodes: self.nodes.prepaint_snapshot(), + node_ids: self.node_ids.clone(), + next_node_id: self.next_node_id, + focus_ids: self.focus_ids.clone(), + node_bounds: self.node_bounds.clone(), + } + } + + pub(crate) fn restore_prepaint_snapshot(&mut self, snapshot: A11yPrepaintSnapshot) { + self.nodes.restore_prepaint_snapshot(snapshot.nodes); + self.node_ids = snapshot.node_ids; + self.next_node_id = snapshot.next_node_id; + self.focus_ids = snapshot.focus_ids; + self.node_bounds = snapshot.node_bounds; + // `action_listeners` are populated during paint, while prepaint transactions only + // roll back prepaint side effects. + } +} + +pub(crate) struct A11yNodeBuilder { + ids_stack: SmallVec<[NodeId; 16]>, + nodes_stack: SmallVec<[accesskit::Node; 16]>, + suppression_stack: SmallVec<[bool; 16]>, + ambient_suppression_depth: usize, + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + focus: NodeId, + #[cfg(debug_assertions)] + has_set_focus: bool, +} + +pub(crate) struct A11yPrepaintSnapshot { + nodes: A11yNodeBuilderPrepaintSnapshot, + node_ids: FxHashMap, + next_node_id: u64, + focus_ids: FxHashMap, + node_bounds: FxHashMap>, +} + +struct A11yNodeBuilderPrepaintSnapshot { + ids_stack: SmallVec<[NodeId; 16]>, + nodes_stack: SmallVec<[accesskit::Node; 16]>, + suppression_stack: SmallVec<[bool; 16]>, + ambient_suppression_depth: usize, + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + focus: NodeId, + #[cfg(debug_assertions)] + has_set_focus: bool, +} + +impl A11yNodeBuilder { + fn new() -> Self { + Self { + ids_stack: SmallVec::new(), + nodes_stack: SmallVec::new(), + suppression_stack: SmallVec::new(), + ambient_suppression_depth: 0, + all_nodes: Vec::new(), + seen_ids: FxHashSet::default(), + focus: ROOT_NODE_ID, + #[cfg(debug_assertions)] + has_set_focus: false, + } + } + + pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool { + debug_assert!(!self.ids_stack.is_empty(), "push called before begin_frame"); + + if self.is_suppressed() { + return false; + } + + if !self.seen_ids.insert(id) { + debug_assert!( + false, + "duplicate a11y node id: {id:?}; release builds discard this node" + ); + return false; + } + + self.ids_stack.push(id); + self.nodes_stack.push(node); + self.suppression_stack.push(false); + true + } + + pub(crate) fn pop(&mut self) { + debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node"); + + self.pop_any(); + } + + fn begin_frame(&mut self) { + self.all_nodes.clear(); + self.ids_stack.clear(); + self.nodes_stack.clear(); + self.suppression_stack.clear(); + self.ambient_suppression_depth = 0; + self.seen_ids.clear(); + self.seen_ids.insert(ROOT_NODE_ID); + #[cfg(debug_assertions)] + { + self.has_set_focus = false; + } + + self.ids_stack.push(ROOT_NODE_ID); + self.nodes_stack + .push(accesskit::Node::new(accesskit::Role::Window)); + self.suppression_stack.push(false); + self.focus = ROOT_NODE_ID; + } + + #[cfg(test)] + fn has_node(&self, id: NodeId) -> bool { + id == ROOT_NODE_ID || self.seen_ids.contains(&id) + } + + pub(crate) fn has_current_node(&self, id: NodeId) -> bool { + self.ids_stack.last().copied() == Some(id) && !self.is_suppressed() + } + + pub(crate) fn set_focus(&mut self, id: NodeId) { + #[cfg(debug_assertions)] + { + debug_assert!( + !self.has_set_focus, + "set_focus called more than once in a single frame" + ); + self.has_set_focus = true; + } + self.focus = id; + } + + fn prepaint_snapshot(&self) -> A11yNodeBuilderPrepaintSnapshot { + A11yNodeBuilderPrepaintSnapshot { + ids_stack: self.ids_stack.clone(), + nodes_stack: self.nodes_stack.clone(), + suppression_stack: self.suppression_stack.clone(), + ambient_suppression_depth: self.ambient_suppression_depth, + all_nodes: self.all_nodes.clone(), + seen_ids: self.seen_ids.clone(), + focus: self.focus, + #[cfg(debug_assertions)] + has_set_focus: self.has_set_focus, + } + } + + fn restore_prepaint_snapshot(&mut self, snapshot: A11yNodeBuilderPrepaintSnapshot) { + self.ids_stack = snapshot.ids_stack; + self.nodes_stack = snapshot.nodes_stack; + self.suppression_stack = snapshot.suppression_stack; + self.ambient_suppression_depth = snapshot.ambient_suppression_depth; + self.all_nodes = snapshot.all_nodes; + self.seen_ids = snapshot.seen_ids; + self.focus = snapshot.focus; + #[cfg(debug_assertions)] + { + self.has_set_focus = snapshot.has_set_focus; + } + } + + #[allow(dead_code)] + pub(crate) fn update_current_node_bounds( + &mut self, + id: NodeId, + bounds: Bounds, + scale_factor: f32, + ) -> bool { + let Some(node) = self.current_node_mut(id) else { + return false; + }; + + let scale = scale_factor; + node.set_bounds(accesskit::Rect { + x0: (bounds.origin.x.0 * scale) as f64, + y0: (bounds.origin.y.0 * scale) as f64, + x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64, + y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, + }); + true + } + + #[allow(dead_code)] + pub(crate) fn suppress_current_node(&mut self, id: NodeId) -> bool { + if self.ids_stack.len() <= 1 { + debug_assert!(false, "cannot suppress the root a11y node"); + return false; + } + + if self.ids_stack.last().copied() != Some(id) { + return false; + } + + let Some(suppressed) = self.suppression_stack.last_mut() else { + return false; + }; + + if *suppressed { + return false; + } + + *suppressed = true; + if let Some(id) = self.ids_stack.last() { + self.seen_ids.remove(id); + } + true + } + + #[allow(dead_code)] + pub(crate) fn begin_suppressing_descendants(&mut self) { + self.ambient_suppression_depth += 1; + } + + #[allow(dead_code)] + pub(crate) fn end_suppressing_descendants(&mut self) { + debug_assert!( + self.ambient_suppression_depth > 0, + "end_suppressing_descendants called without matching begin" + ); + self.ambient_suppression_depth = self.ambient_suppression_depth.saturating_sub(1); + } + + fn finalize(&mut self) -> TreeUpdate { + debug_assert_eq!(self.ids_stack.len(), 1); + debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID); + debug_assert_eq!(self.ambient_suppression_depth, 0); + + if self.ids_stack.len() != 1 { + log::error!( + "a11y: stack imbalance at end of frame: expected 1 (root), got {}", + self.ids_stack.len() + ); + } + if self.ambient_suppression_depth != 0 { + log::error!( + "a11y: ambient suppression imbalance at end of frame: got {}", + self.ambient_suppression_depth + ); + self.ambient_suppression_depth = 0; + } + + while !self.ids_stack.is_empty() { + self.pop_any(); + } + + let update = TreeUpdate { + nodes: std::mem::take(&mut self.all_nodes), + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: self.focus, + }; + + Self::repair_tree_update(update) + } + + #[allow(dead_code)] + fn current_node_mut(&mut self, id: NodeId) -> Option<&mut accesskit::Node> { + if self.ids_stack.len() <= 1 + || self.ids_stack.last().copied() != Some(id) + || self.suppression_stack.last().copied().unwrap_or(true) + { + None + } else { + self.nodes_stack.last_mut() + } + } + + fn is_suppressed(&self) -> bool { + self.ambient_suppression_depth > 0 + || self.suppression_stack.last().copied().unwrap_or_default() + } + + fn pop_any(&mut self) { + if let (Some(id), Some(node), Some(suppressed)) = ( + self.ids_stack.pop(), + self.nodes_stack.pop(), + self.suppression_stack.pop(), + ) { + if suppressed { + return; + } + + if let (Some(parent), Some(parent_suppressed)) = + (self.nodes_stack.last_mut(), self.suppression_stack.last()) + { + if !*parent_suppressed { + parent.push_child(id); + } + } + self.all_nodes.push((id, node)); + } + } + + fn repair_tree_update(mut update: TreeUpdate) -> TreeUpdate { + let node_ids: FxHashSet = update.nodes.iter().map(|(id, _)| *id).collect(); + + if !node_ids.contains(&update.focus) { + log::error!( + "a11y: focused node {:?} is not in the tree ({} nodes); falling back to root", + update.focus, + update.nodes.len() + ); + update.focus = ROOT_NODE_ID; + } + + for (id, node) in &mut update.nodes { + let has_invalid_child = node + .children() + .iter() + .any(|child_id| !node_ids.contains(child_id)); + if has_invalid_child { + let children = node.children(); + let invalid_count = children + .iter() + .filter(|child_id| !node_ids.contains(child_id)) + .count(); + log::error!( + "a11y: node {:?} references {} children not present in the tree; stripping invalid child references", + id, + invalid_count + ); + let valid = children + .iter() + .copied() + .filter(|child_id| node_ids.contains(child_id)) + .collect::>(); + node.set_children(valid); + } + } + + update + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ElementId, point, px, size}; + use slotmap::KeyData; + use std::sync::{Arc, atomic::AtomicBool}; + + fn global_id(id: impl Into) -> GlobalElementId { + GlobalElementId(Arc::from([id.into()])) + } + + fn node<'a>(update: &'a TreeUpdate, id: NodeId) -> &'a accesskit::Node { + update + .nodes + .iter() + .find_map(|(node_id, node)| (*node_id == id).then_some(node)) + .unwrap() + } + + #[test] + fn preserves_unsuppressed_tree_shape() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); + assert_eq!(node(&update, NodeId(2)).children(), &[]); + } + + #[test] + fn updates_current_non_root_node_bounds() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.update_current_node_bounds( + NodeId(1), + Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), + 2., + )); + builder.pop(); + + let update = builder.finalize(); + assert_eq!( + node(&update, NodeId(1)).bounds(), + Some(accesskit::Rect { + x0: 4., + y0: 6., + x1: 12., + y1: 16., + }) + ); + } + + #[test] + fn suppresses_current_node_before_finalization() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.has_node(NodeId(1))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 1); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); + } + + #[test] + fn skips_descendants_while_current_node_is_suppressed() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 2); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(2)]); + assert_eq!(node(&update, NodeId(2)).children(), &[]); + } + + #[test] + fn ambient_descendant_suppression_under_root_skips_child_push_without_suppressing_root() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + builder.begin_suppressing_descendants(); + assert!(!builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.end_suppressing_descendants(); + + assert!(builder.has_node(ROOT_NODE_ID)); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 2); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + } + + #[test] + fn id_specific_update_requires_current_node() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(!builder.update_current_node_bounds( + NodeId(2), + Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), + 2., + )); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(node(&update, NodeId(1)).bounds(), None); + } + + #[test] + fn id_specific_suppress_requires_current_node() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(!builder.suppress_current_node(NodeId(2))); + assert!(builder.has_node(NodeId(1))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); + } + + #[test] + fn suppressing_focused_current_node_removes_focus_from_tree() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.set_focus(NodeId(1)); + assert!(builder.suppress_current_node(NodeId(1))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.focus, ROOT_NODE_ID); + assert_eq!(update.nodes.len(), 1); + } + + #[test] + fn parent_children_do_not_reference_suppressed_descendants() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + assert!(builder.suppress_current_node(NodeId(2))); + builder.pop(); + + assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(3)]); + } + + #[test] + fn restores_prepaint_snapshot_for_builder_state() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + let snapshot = builder.prepaint_snapshot(); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.begin_suppressing_descendants(); + assert!(!builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); + builder.set_focus(NodeId(1)); + + builder.restore_prepaint_snapshot(snapshot); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.set_focus(NodeId(2)); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(update.focus, NodeId(2)); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); + } + + #[test] + fn restores_prepaint_snapshot_for_a11y_maps() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + a11y.begin_frame(); + + let focus_id = FocusId::from(KeyData::from_ffi(1)); + let original_bounds = Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))); + a11y.focus_ids.insert(NodeId(1), focus_id); + a11y.node_bounds.insert(NodeId(1), original_bounds); + let snapshot = a11y.prepaint_snapshot(); + + a11y.focus_ids.remove(&NodeId(1)); + a11y.focus_ids + .insert(NodeId(2), FocusId::from(KeyData::from_ffi(2))); + a11y.node_bounds.insert( + NodeId(1), + Bounds::new(point(px(5.), px(6.)), size(px(7.), px(8.))), + ); + a11y.node_bounds.insert( + NodeId(2), + Bounds::new(point(px(9.), px(10.)), size(px(11.), px(12.))), + ); + + a11y.restore_prepaint_snapshot(snapshot); + + assert_eq!(a11y.focus_ids.len(), 1); + assert_eq!(a11y.focus_ids.get(&NodeId(1)), Some(&focus_id)); + assert_eq!(a11y.node_bounds.len(), 1); + assert_eq!(a11y.node_bounds.get(&NodeId(1)), Some(&original_bounds)); + } + + #[test] + fn allocates_stable_unique_node_ids_for_global_element_ids() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let first_id = global_id("first"); + let second_id = global_id("second"); + + let first_node_id = a11y.node_id_for(&first_id); + let second_node_id = a11y.node_id_for(&second_id); + a11y.begin_frame(); + + assert_ne!(first_node_id, ROOT_NODE_ID); + assert_ne!(second_node_id, ROOT_NODE_ID); + assert_ne!(first_node_id, second_node_id); + assert_eq!(a11y.node_id_for(&first_id), first_node_id); + assert_eq!(a11y.node_id_for_existing(&second_id), Some(second_node_id)); + assert_eq!(a11y.node_id_for_existing(&global_id("missing")), None); + } + + #[test] + fn restores_prepaint_snapshot_for_node_id_allocator() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let accepted_id = global_id("accepted"); + let rejected_id = global_id("rejected"); + let next_id = global_id("next"); + + let accepted_node_id = a11y.node_id_for(&accepted_id); + let snapshot = a11y.prepaint_snapshot(); + let rejected_node_id = a11y.node_id_for(&rejected_id); + + a11y.restore_prepaint_snapshot(snapshot); + let next_node_id = a11y.node_id_for(&next_id); + + assert_eq!( + a11y.node_id_for_existing(&accepted_id), + Some(accepted_node_id) + ); + assert_eq!(a11y.node_id_for_existing(&rejected_id), None); + assert_eq!(next_node_id, rejected_node_id); + } +} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index b687daf..1e73f1a 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -53,6 +53,8 @@ screen-capture = [ [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +accesskit.workspace = true +accesskit_unix.workspace = true anyhow.workspace = true bytemuck = "1" collections.workspace = true diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index b89daeb..d236a6b 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -445,6 +445,7 @@ impl WaylandClientState { "wayland: no focused surface to restore cursor style {:?} after hide; cursor may stay invisible", style ); + self.cursor_hidden_window = None; return; }; let Some(wl_pointer) = self.wl_pointer.clone() else { @@ -452,6 +453,7 @@ impl WaylandClientState { "wayland: no wl_pointer to restore cursor style {:?} after hide; cursor may stay invisible", style ); + self.cursor_hidden_window = None; return; }; let scale = focused_window.primary_output_scale(); @@ -925,7 +927,7 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set_primary(item); - let serial = state.serial_tracker.get(SerialKind::KeyPress); + let serial = state.serial_tracker.get_latest(); let data_source = primary_selection_manager.create_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); @@ -945,7 +947,7 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set(item); - let serial = state.serial_tracker.get(SerialKind::KeyPress); + let serial = state.serial_tracker.get_latest(); let data_source = data_device_manager.create_data_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); diff --git a/crates/gpui_linux/src/linux/wayland/serial.rs b/crates/gpui_linux/src/linux/wayland/serial.rs index eadc7a9..75fb69e 100644 --- a/crates/gpui_linux/src/linux/wayland/serial.rs +++ b/crates/gpui_linux/src/linux/wayland/serial.rs @@ -46,4 +46,22 @@ impl SerialTracker { .map(|serial_data| serial_data.serial) .unwrap_or(0) } + + /// Returns the most recent serial across all tracked kinds. + /// + /// Wayland compositor serial numbers are monotonically increasing, so the + /// highest value is always the most recently received one. This is the + /// correct serial to use for `set_selection` when the triggering event + /// may have been a mouse press rather than a key press: using 0 (the + /// default when a kind has never been seen) causes compositors to silently + /// reject the request. + /// + /// Returns 0 only if no serial of any kind has been received yet. + pub fn get_latest(&self) -> u32 { + self.serials + .values() + .map(|serial_data| serial_data.serial) + .max() + .unwrap_or(0) + } } diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 1173f3d..1741bc4 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -121,6 +121,7 @@ pub struct WaylandWindowState { in_progress_window_controls: Option, window_controls: WindowControls, client_inset: Option, + accesskit_adapter: Option, } pub enum WaylandSurfaceState { @@ -343,6 +344,7 @@ impl WaylandWindowState { height: DevicePixels(f32::from(options.bounds.size.height) as i32), }, transparent: true, + preferred_present_mode: Some(wgpu::PresentMode::Mailbox), }; WgpuRenderer::new(gpu_context, &raw_window, config)? }; @@ -393,6 +395,7 @@ impl WaylandWindowState { in_progress_window_controls: None, window_controls: WindowControls::default(), client_inset: None, + accesskit_adapter: None, }) } @@ -1027,6 +1030,9 @@ impl WaylandWindowStatePtr { if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { fun(focus); } + if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() { + adapter.update_window_focus_state(focus); + } } pub fn set_hovered(&self, focus: bool) { @@ -1468,6 +1474,60 @@ impl PlatformWindow for WaylandWindow { bell.ring(surface); } } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let activation_handler = TrivialActivationHandler { + callback: callbacks.activation, + }; + let action_handler = TrivialActionHandler(callbacks.action); + let deactivation_handler = TrivialDeactivationHandler { + callback: callbacks.deactivation, + }; + + let adapter = + accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + + self.borrow_mut().accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let mut state = self.borrow_mut(); + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.update_if_active(|| tree_update); + } + } + + fn a11y_update_window_bounds(&self) { + // Wayland does not expose window position. + } +} + +struct TrivialActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct TrivialActionHandler(Box); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +struct TrivialDeactivationHandler { + callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } } fn update_window(mut state: RefMut) { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index e1705c5..3b2cade 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -282,6 +282,7 @@ pub struct X11WindowState { edge_constraints: Option, pub handle: AnyWindowHandle, last_insets: [u32; 4], + accesskit_adapter: Option, } impl X11WindowState { @@ -735,6 +736,7 @@ impl X11WindowState { // If the window appearance changes, then the renderer will get updated // too transparent: false, + preferred_present_mode: None, }; WgpuRenderer::new(gpu_context, &raw_window, config)? }; @@ -788,6 +790,7 @@ impl X11WindowState { decorations: WindowDecorations::Server, last_insets: [0, 0, 0, 0], edge_constraints: None, + accesskit_adapter: None, counter_id: sync_request_counter, last_sync_counter: None, }) @@ -1261,6 +1264,9 @@ impl X11WindowStatePtr { if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { fun(focus); } + if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() { + adapter.update_window_focus_state(focus); + } } pub fn set_hovered(&self, focus: bool) { @@ -1862,4 +1868,84 @@ impl PlatformWindow for X11Window { check_reply(|| "X11 Bell failed.", self.0.xcb.bell(0)).log_err(); xcb_flush(&self.0.xcb); } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let activation_handler = TrivialActivationHandler { + callback: callbacks.activation, + }; + let action_handler = TrivialActionHandler(callbacks.action); + let deactivation_handler = TrivialDeactivationHandler { + callback: callbacks.deactivation, + }; + + let adapter = + accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + + self.0.state.borrow_mut().accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let mut state = self.0.state.borrow_mut(); + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.update_if_active(|| tree_update); + } + } + + fn a11y_update_window_bounds(&self) { + let mut state = self.0.state.borrow_mut(); + let scale = state.scale_factor; + let bounds = state.bounds; + let [left, right, top, bottom] = state.last_insets; + + let x = f32::from(bounds.origin.x); + let y = f32::from(bounds.origin.y); + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + + let outer = accesskit::Rect { + x0: (x * scale) as f64, + y0: (y * scale) as f64, + x1: ((x + width) * scale) as f64, + y1: ((y + height) * scale) as f64, + }; + + let inner = accesskit::Rect { + x0: (x * scale) as f64 + left as f64, + y0: (y * scale) as f64 + top as f64, + x1: ((x + width) * scale) as f64 - right as f64, + y1: ((y + height) * scale) as f64 - bottom as f64, + }; + + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.set_root_window_bounds(outer, inner); + } + } +} + +struct TrivialActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct TrivialActionHandler(Box); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +struct TrivialDeactivationHandler { + callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 06e5d0e..5e85879 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -22,6 +22,8 @@ screen-capture = ["gpui/screen-capture"] gpui.workspace = true [target.'cfg(target_os = "macos")'.dependencies] +accesskit.workspace = true +accesskit_macos.workspace = true anyhow.workspace = true async-task = "4.7" block = "0.1" diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 0ee2fcd..05d47ed 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -67,7 +67,7 @@ const MAC_PLATFORM_IVAR: &str = "platform"; static mut APP_CLASS: *const Class = ptr::null(); static mut APP_DELEGATE_CLASS: *const Class = ptr::null(); -#[ctor] +#[ctor(unsafe)] unsafe fn build_classes() { unsafe { APP_CLASS = { @@ -622,8 +622,10 @@ impl Platform for MacPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { - let renderer_context = self.0.lock().renderer_context.clone(); - let cursor_visible = self.0.lock().cursor_visible.clone(); + let (renderer_context, cursor_visible) = { + let state = self.0.lock(); + (state.renderer_context.clone(), state.cursor_visible.clone()) + }; Ok(Box::new(MacWindow::open( handle, options, diff --git a/crates/gpui_macos/src/screen_capture.rs b/crates/gpui_macos/src/screen_capture.rs index a7eaa3e..5ece21a 100644 --- a/crates/gpui_macos/src/screen_capture.rs +++ b/crates/gpui_macos/src/screen_capture.rs @@ -284,7 +284,7 @@ pub(crate) fn get_sources() -> oneshot::Receiver i32; } -#[ctor] +#[ctor(unsafe)] unsafe fn build_classes() { unsafe { WINDOW_CLASS = build_window_class("GPUIWindow", class!(NSWindow)); @@ -446,6 +446,7 @@ struct MacWindowState { activated_least_once: bool, closed: Arc, cursor_visible: Arc, + accesskit_adapter: Option, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, } @@ -773,6 +774,7 @@ impl MacWindow { activated_least_once: false, closed: Arc::new(AtomicBool::new(false)), cursor_visible, + accesskit_adapter: None, sheet_parent: None, }))); @@ -1740,6 +1742,41 @@ impl PlatformWindow for MacWindow { unsafe { NSBeep() } } + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let mut lock = self.0.lock(); + + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + let action_handler = A11yActionHandler(callbacks.action); + + let adapter = unsafe { + accesskit_macos::SubclassingAdapter::for_window( + lock.native_window as *mut c_void, + activation_handler, + action_handler, + ) + }; + + lock.accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let events = { + let mut lock = self.0.lock(); + lock.accesskit_adapter + .as_mut() + .and_then(|adapter| adapter.update_if_active(|| tree_update)) + }; + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // macOS handles window bounds tracking automatically via NSAccessibility. + } + #[cfg(feature = "test-support")] fn render_to_image(&self, scene: &gpui::Scene) -> Result { let mut this = self.0.lock(); @@ -1747,6 +1784,24 @@ impl PlatformWindow for MacWindow { } } +struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + impl rwh::HasWindowHandle for MacWindow { fn window_handle(&self) -> Result, rwh::HandleError> { // SAFETY: The AppKitWindowHandle is a wrapper around a pointer to an NSView @@ -2248,6 +2303,16 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let executor = lock.foreground_executor.clone(); drop(lock); + let a11y_events = { + let mut lock = window_state.lock(); + lock.accesskit_adapter + .as_mut() + .and_then(|adapter| adapter.update_view_focus_state(is_active)) + }; + if let Some(events) = a11y_events { + events.raise(); + } + // When a window becomes active, trigger an immediate synchronous frame request to prevent // tab flicker when switching between windows in native tabs mode. // diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs index 3d8351f..534065d 100644 --- a/crates/gpui_macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -409,6 +409,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(1.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }]); self } @@ -424,6 +425,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(1.)), blur_radius: px(2.), spread_radius: px(0.), + inset: false, }]); self } @@ -440,12 +442,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(1.)), blur_radius: px(3.), spread_radius: px(0.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.1), offset: point(px(0.), px(1.)), blur_radius: px(2.), spread_radius: px(-1.), + inset: false, } ]); self @@ -463,12 +467,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(4.)), blur_radius: px(6.), spread_radius: px(-1.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.1), offset: point(px(0.), px(2.)), blur_radius: px(4.), spread_radius: px(-2.), + inset: false, } ]); self @@ -486,12 +492,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(10.)), blur_radius: px(15.), spread_radius: px(-3.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.1), offset: point(px(0.), px(4.)), blur_radius: px(6.), spread_radius: px(-4.), + inset: false, } ]); self @@ -509,12 +517,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(20.)), blur_radius: px(25.), spread_radius: px(-5.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.1), offset: point(px(0.), px(8.)), blur_radius: px(10.), spread_radius: px(-6.), + inset: false, } ]); self @@ -531,6 +541,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(25.)), blur_radius: px(50.), spread_radius: px(-12.), + inset: false, }]); self } diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index b8703c4..4459f98 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -144,6 +144,7 @@ impl WebWindow { let renderer_config = WgpuSurfaceConfig { size: device_size, transparent: false, + preferred_present_mode: None, }; let renderer = WgpuRenderer::new_from_canvas(context, &canvas, renderer_config)?; diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index 4d9ee02..3520e8e 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -20,7 +20,7 @@ gpui.workspace = true anyhow.workspace = true bytemuck = "1" collections.workspace = true -cosmic-text = "0.17.0" +cosmic-text = "0.19.0" etagere = "0.2" itertools.workspace = true log.workspace = true @@ -29,6 +29,7 @@ profiling.workspace = true raw-window-handle = "0.6" smallvec.workspace = true swash = "0.2.6" +unicode-segmentation.workspace = true gpui_util.workspace = true wgpu.workspace = true @@ -43,3 +44,10 @@ wasm-bindgen.workspace = true wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["HtmlCanvasElement"] } js-sys = "0.3" + +[dev-dependencies] +criterion.workspace = true + +[[bench]] +name = "layout_line" +harness = false diff --git a/crates/gpui_wgpu/benches/layout_line.rs b/crates/gpui_wgpu/benches/layout_line.rs new file mode 100644 index 0000000..94fae68 --- /dev/null +++ b/crates/gpui_wgpu/benches/layout_line.rs @@ -0,0 +1,87 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui::{FontFallbacks, FontRun, PlatformTextSystem, font, px}; +use gpui_wgpu::CosmicTextSystem; +use std::borrow::Cow; + +const LILEX: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../gpui_web/assets/fonts/lilex/Lilex-Regular.ttf" +)); +const IBM_PLEX: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../gpui_web/assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" +)); + +// 991 chars of typical ASCII code text. +fn code_text() -> String { + concat!( + " fn compute_run_spans(\n", + " text: &str,\n", + " run_offset: usize,\n", + " run_len: usize,\n", + " primary: FontId,\n", + " fallback_chain: &[(FontId, SharedString)],\n", + " covers: &impl Fn(FontId, char) -> bool,\n", + " ) -> SmallVec<[RunSpan; 4]> {\n", + " let mut spans = SmallVec::new();\n", + " let run_end = run_offset + run_len;\n", + " if run_end <= run_offset { return spans; }\n", + " let run_text = &text[run_offset..run_end];\n", + " let mut span_start = run_offset;\n", + " let mut span_slot: Option = None;\n", + " for (ch_idx, ch) in run_text.char_indices() {\n", + " let abs = run_offset + ch_idx;\n", + " let next = pick_covering_slot(ch, span_slot, primary, fallback_chain, covers);\n", + " if next == span_slot { continue; }\n", + " if abs > span_start {\n", + " spans.push(RunSpan { start: span_start, end: abs, slot: span_slot });\n", + " }\n", + " span_start = abs;\n", + " span_slot = next;\n", + " }\n", + " spans\n", + " }\n", + ) + .repeat(8) // 7,928 chars +} + +fn bench_layout_line(c: &mut Criterion) { + let system = CosmicTextSystem::new_without_system_fonts("Lilex"); + system + .add_fonts(vec![Cow::Borrowed(LILEX), Cow::Borrowed(IBM_PLEX)]) + .unwrap(); + + let font_id_no_fallback = system.font_id(&font("Lilex")).unwrap(); + + let font_id_with_fallback = { + let mut f = font("Lilex"); + f.fallbacks = Some(FontFallbacks::from_fonts(vec!["IBM Plex Sans".to_string()])); + system.font_id(&f).unwrap() + }; + + let text = code_text(); + + let runs_no_fallback = vec![FontRun { + len: text.len(), + font_id: font_id_no_fallback, + }]; + let runs_with_fallback = vec![FontRun { + len: text.len(), + font_id: font_id_with_fallback, + }]; + + let mut group = c.benchmark_group("layout_line"); + + group.bench_function("no_fallback", |b| { + b.iter(|| system.layout_line(&text, px(14.0), &runs_no_fallback)) + }); + + group.bench_function("fallback_chain_ascii_text", |b| { + b.iter(|| system.layout_line(&text, px(14.0), &runs_with_fallback)) + }); + + group.finish(); +} + +criterion_group!(benches, bench_layout_line); +criterion_main!(benches); diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index a9b299d..5670af1 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -1,13 +1,14 @@ use anyhow::{Context as _, Ok, Result}; use collections::HashMap; use cosmic_text::{ - Attrs, AttrsList, Family, Font as CosmicTextFont, FontFeatures as CosmicFontFeatures, - FontSystem, ShapeBuffer, ShapeLine, + Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont, + FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, }; use gpui::{ - Bounds, DevicePixels, Font, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, LineLayout, - Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, - ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, size, + Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, + LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, + SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, + size, }; use itertools::Itertools; @@ -18,6 +19,7 @@ use swash::{ scale::{Render, ScaleContext, Source, StrikeWith}, zeno::{Format, Vector}, }; +use unicode_segmentation::UnicodeSegmentation; pub struct CosmicTextSystem(RwLock); @@ -25,11 +27,16 @@ pub struct CosmicTextSystem(RwLock); struct FontKey { family: SharedString, features: FontFeatures, + fallbacks: Option, } impl FontKey { - fn new(family: SharedString, features: FontFeatures) -> Self { - Self { family, features } + fn new(family: SharedString, features: FontFeatures, fallbacks: Option) -> Self { + Self { + family, + features, + fallbacks, + } } } @@ -49,6 +56,7 @@ struct LoadedFont { font: Arc, features: CosmicFontFeatures, is_known_emoji_font: bool, + user_fallback_chain: Arc<[(FontId, SharedString)]>, } impl CosmicTextSystem { @@ -103,11 +111,16 @@ impl PlatformTextSystem for CosmicTextSystem { fn font_id(&self, font: &Font) -> Result { let mut state = self.0.write(); - let key = FontKey::new(font.family.clone(), font.features.clone()); + let key = FontKey::new( + font.family.clone(), + font.features.clone(), + font.fallbacks.clone(), + ); let candidates = if let Some(font_ids) = state.font_ids_by_family_cache.get(&key) { font_ids.as_slice() } else { - let font_ids = state.load_family(&font.family, &font.features)?; + let font_ids = + state.load_family(&font.family, &font.features, font.fallbacks.as_ref())?; state.font_ids_by_family_cache.insert(key.clone(), font_ids); state.font_ids_by_family_cache[&key].as_ref() }; @@ -227,7 +240,40 @@ impl CosmicTextSystemState { &mut self, name: &str, features: &FontFeatures, + fallbacks: Option<&FontFallbacks>, ) -> Result> { + let user_fallback_chain: Arc<[(FontId, SharedString)]> = match fallbacks { + Some(fallbacks) if !fallbacks.fallback_list().is_empty() => { + let mut chain: Vec<(FontId, SharedString)> = Vec::new(); + for fallback_name in fallbacks.fallback_list() { + let fb_key = FontKey::new( + SharedString::from(fallback_name.clone()), + features.clone(), + None, + ); + let fb_ids = if let Some(cached) = self.font_ids_by_family_cache.get(&fb_key) { + cached.clone() + } else { + let loaded = self.load_family(fallback_name, features, None)?; + self.font_ids_by_family_cache + .insert(fb_key.clone(), loaded.clone()); + loaded + }; + let Some(&fb_id) = fb_ids.first() else { + continue; + }; + let db_id = self.loaded_fonts[fb_id.0].font.id(); + if let Some(face) = self.font_system.db().face(db_id) { + if let Some(family) = face.families.first() { + chain.push((fb_id, SharedString::from(family.0.clone()))); + } + } + } + Arc::from(chain) + } + _ => Arc::from(Vec::new()), + }; + let name = gpui::font_name_with_fallbacks(name, &self.system_font_fallback); let families = self @@ -238,6 +284,8 @@ impl CosmicTextSystemState { .map(|face| (face.id, face.post_script_name.clone())) .collect::>(); + let cosmic_features = cosmic_font_features(features)?; + let mut loaded_font_ids = SmallVec::new(); for (font_id, postscript_name) in families { let font = self @@ -262,8 +310,9 @@ impl CosmicTextSystemState { loaded_font_ids.push(font_id); self.loaded_fonts.push(LoadedFont { font, - features: cosmic_font_features(features)?, + features: cosmic_features.clone(), is_known_emoji_font: check_is_known_emoji_font(&postscript_name), + user_fallback_chain: Arc::clone(&user_fallback_chain), }); } @@ -404,6 +453,7 @@ impl CosmicTextSystemState { font, features: CosmicFontFeatures::new(), is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name), + user_fallback_chain: Arc::from(Vec::new()), }); Ok(font_id) @@ -415,13 +465,15 @@ impl CosmicTextSystemState { let mut attrs_list = AttrsList::new(&Attrs::new()); let mut offs = 0; for run in font_runs { + let run_end = offs + run.len; + let loaded_font = self.loaded_font(run.font_id); let Some(face) = self.font_system.db().face(loaded_font.font.id()) else { log::warn!( "font face not found in database for font_id {:?}", run.font_id ); - offs += run.len; + offs = run_end; continue; }; let Some(first_family) = face.families.first() else { @@ -429,21 +481,60 @@ impl CosmicTextSystemState { "font face has no family names for font_id {:?}", run.font_id ); - offs += run.len; + offs = run_end; continue; }; - attrs_list.add_span( - offs..(offs + run.len), - &Attrs::new() - .metadata(run.font_id.0) - .family(Family::Name(&first_family.0)) - .stretch(face.stretch) - .style(face.style) - .weight(face.weight) - .font_features(loaded_font.features.clone()), - ); - offs += run.len; + let primary_family_name: SharedString = first_family.0.clone().into(); + let primary_stretch = face.stretch; + let primary_style = face.style; + let primary_weight = face.weight; + let primary_features = loaded_font.features.clone(); + let fallback_chain = Arc::clone(&loaded_font.user_fallback_chain); + + let primary_attrs = Attrs::new() + .metadata(run.font_id.0) + .family(Family::Name(&primary_family_name)) + .stretch(primary_stretch) + .style(primary_style) + .weight(primary_weight) + .font_features(primary_features.clone()); + let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = fallback_chain + .iter() + .map(|(fb_id, fb_name)| { + Attrs::new() + .metadata(fb_id.0) + .family(Family::Name(fb_name)) + .stretch(primary_stretch) + .style(primary_style) + .weight(primary_weight) + .font_features(primary_features.clone()) + }) + .collect(); + + let spans = if fallback_chain.is_empty() { + let mut spans = SmallVec::<[RunSpan; 4]>::new(); + spans.push(RunSpan { + start: offs, + end: run_end, + slot: None, + font_id: run.font_id, + }); + spans + } else { + let loaded_fonts = &self.loaded_fonts; + let covers = |id: FontId, ch: char| charmap_covers(loaded_fonts, id, ch); + compute_run_spans(text, offs, run.len, run.font_id, &fallback_chain, &covers) + }; + + for span in spans { + let attrs = match span.slot { + None => &primary_attrs, + Some(ix) => &fallback_attrs[ix], + }; + attrs_list.add_span(span.start..span.end, attrs); + } + offs = run_end; } let line = ShapeLine::new( @@ -459,6 +550,7 @@ impl CosmicTextSystemState { f32::from(font_size), None, // We do our own wrapping cosmic_text::Wrap::None, + Ellipsize::None, None, &mut layout_lines, None, @@ -606,6 +698,115 @@ fn find_best_match( Ok(best_index) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RunSpan { + start: usize, + end: usize, + slot: Option, + font_id: FontId, +} + +fn compute_run_spans( + text: &str, + run_offset: usize, + run_len: usize, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], + covers: &impl Fn(FontId, char) -> bool, +) -> SmallVec<[RunSpan; 4]> { + let mut spans = SmallVec::new(); + let run_end = run_offset + run_len; + if run_end <= run_offset { + return spans; + } + if fallback_chain.is_empty() { + spans.push(RunSpan { + start: run_offset, + end: run_end, + slot: None, + font_id: primary, + }); + return spans; + } + + let run_text = &text[run_offset..run_end]; + let mut span_start = run_offset; + let mut span_slot: Option = None; + let mut span_font_id = primary; + for (grapheme_idx, grapheme) in run_text.grapheme_indices(true) { + let abs = run_offset + grapheme_idx; + let next_slot = pick_covering_slot(grapheme, primary, fallback_chain, covers); + if next_slot == span_slot { + continue; + } + if abs > span_start { + spans.push(RunSpan { + start: span_start, + end: abs, + slot: span_slot, + font_id: span_font_id, + }); + } + span_start = abs; + span_slot = next_slot; + span_font_id = slot_font_id(next_slot, primary, fallback_chain); + } + if span_start < run_end { + spans.push(RunSpan { + start: span_start, + end: run_end, + slot: span_slot, + font_id: span_font_id, + }); + } + spans +} + +fn slot_font_id( + slot: Option, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], +) -> FontId { + match slot { + None => primary, + Some(ix) => fallback_chain[ix].0, + } +} + +fn pick_covering_slot( + grapheme: &str, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], + covers: &impl Fn(FontId, char) -> bool, +) -> Option { + if grapheme.is_ascii() { + return None; + } + if slot_covers_grapheme(primary, grapheme, covers) { + return None; + } + for (ix, (fb_id, _)) in fallback_chain.iter().enumerate() { + if slot_covers_grapheme(*fb_id, grapheme, covers) { + return Some(ix); + } + } + None +} + +fn slot_covers_grapheme( + font_id: FontId, + grapheme: &str, + covers: &impl Fn(FontId, char) -> bool, +) -> bool { + grapheme.chars().all(|ch| covers(font_id, ch)) +} + +fn charmap_covers(loaded_fonts: &[LoadedFont], id: FontId, ch: char) -> bool { + loaded_fonts + .get(id.0) + .is_some_and(|loaded| loaded.font.as_swash().charmap().map(ch) != 0) +} + fn cosmic_font_features(features: &FontFeatures) -> Result { let mut result = CosmicFontFeatures::new(); for feature in features.0.iter() { @@ -670,6 +871,230 @@ mod tests { use super::*; use gpui::font; + fn fid(i: usize) -> FontId { + FontId(i) + } + + fn chain(ids: &[usize]) -> SmallVec<[(FontId, SharedString); 4]> { + ids.iter() + .map(|&i| (fid(i), SharedString::from(format!("fb{i}")))) + .collect() + } + + fn span(start: usize, end: usize, slot: Option, font_id: FontId) -> RunSpan { + RunSpan { + start, + end, + slot, + font_id, + } + } + + #[test] + fn primary_preferred_over_fallback_when_both_cover() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |_: FontId, _: char| true; + + assert_eq!( + pick_covering_slot("a", primary, &fallback_chain, &covers), + None + ); + } + + #[test] + fn primary_wins_over_fallback_when_primary_covers() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2]); + let covers = |id: FontId, _: char| id == fid(0) || id == fid(1); + + assert_eq!( + pick_covering_slot("a", primary, &fallback_chain, &covers), + None + ); + } + + #[test] + fn fallback_chain_is_checked_in_order() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2, 3]); + let covers = |id: FontId, _: char| id == fid(2); + + assert_eq!( + pick_covering_slot("字", primary, &fallback_chain, &covers), + Some(1) + ); + } + + #[test] + fn fallback_must_cover_every_codepoint_in_grapheme() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2]); + let covers = |id: FontId, ch: char| match id.0 { + 0 | 1 => ch == 'e', + 2 => ch == 'e' || ch == '\u{0301}', + _ => false, + }; + + assert_eq!( + pick_covering_slot("e\u{0301}", primary, &fallback_chain, &covers), + Some(1) + ); + } + + #[test] + fn no_coverage_returns_primary_for_cosmic_text_fallback() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2]); + let covers = |_: FontId, _: char| false; + + assert_eq!( + pick_covering_slot("\u{1F600}", primary, &fallback_chain, &covers), + None + ); + } + + #[test] + fn empty_chain_always_returns_primary() { + let primary = fid(0); + let fallback_chain: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new(); + let covers = |_: FontId, _: char| false; + + assert_eq!( + pick_covering_slot("a", primary, &fallback_chain, &covers), + None + ); + } + + #[test] + fn slot_font_id_resolution() { + let primary = fid(7); + let fallback_chain = chain(&[10, 20]); + + assert_eq!(slot_font_id(None, primary, &fallback_chain), fid(7)); + assert_eq!(slot_font_id(Some(0), primary, &fallback_chain), fid(10)); + assert_eq!(slot_font_id(Some(1), primary, &fallback_chain), fid(20)); + } + + #[test] + fn run_spans_with_no_chain_emit_one_primary_span() { + let primary = fid(0); + let fallback_chain: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new(); + let covers = |_: FontId, _: char| false; + let text = "hello"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_use_byte_offsets_for_multibyte_chars() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "a字b"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!( + spans.as_slice(), + &[ + span(0, 1, None, primary), + span(1, 4, Some(0), fid(1)), + span(4, 5, None, primary), + ] + ); + } + + #[test] + fn run_spans_respect_run_offset() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "xx字y"; + let run_offset = 2; + let run_len = text.len() - run_offset; + + let spans = compute_run_spans(text, run_offset, run_len, primary, &fallback_chain, &covers); + + assert_eq!( + spans.as_slice(), + &[span(2, 5, Some(0), fid(1)), span(5, 6, None, primary)] + ); + } + + #[test] + fn run_spans_do_not_use_fallback_for_partially_covered_grapheme() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + ch == '\u{0905}' + } + }; + let text = "\u{0905}\u{0902}"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_do_not_use_fallback_for_partially_covered_zwj_cluster() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| id == fid(1) && ch != '\u{200D}'; + let text = "\u{1F469}\u{200D}\u{1F467}"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_collapse_adjacent_same_slot() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "字字字"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]); + } + + #[test] + fn run_spans_empty_run_returns_no_spans() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |_: FontId, _: char| true; + + let spans = compute_run_spans("anything", 3, 0, primary, &fallback_chain, &covers); + + assert!(spans.is_empty()); + } + #[test] fn typographic_bounds_use_glyph_outline_bounds() { let text_system = CosmicTextSystem::new_without_system_fonts("IBM Plex Sans"); diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index eb2cbeb..60e1d3b 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -952,10 +952,18 @@ fn fmod(a: f32, b: f32) -> f32 { struct Shadow { order: u32, blur_radius: f32, + // The shadow rect for drop shadows; the "hole" rect for inset shadows. bounds: Bounds, corner_radii: Corners, content_mask: Bounds, color: Hsla, + // Only consulted when `inset == 1u`: the element's own bounds, used as a rounded-rect + // clip so the shadow never escapes the element. + element_bounds: Bounds, + element_corner_radii: Corners, + // 0 = drop shadow, 1 = inset shadow. + inset: u32, + pad: u32, // align to 8 bytes } @group(1) @binding(0) var b_shadows: array; @@ -972,17 +980,22 @@ fn vs_shadow(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) ins let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); var shadow = b_shadows[instance_id]; - let margin = 3.0 * shadow.blur_radius; - // Set the bounds of the shadow and adjust its size based on the shadow's - // spread radius to achieve the spreading effect - shadow.bounds.origin -= vec2(margin); - shadow.bounds.size += 2.0 * vec2(margin); + var geometry: Bounds; + if (shadow.inset != 0u) { + geometry = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + let margin = 3.0 * shadow.blur_radius; + geometry = shadow.bounds; + geometry.origin -= vec2(margin); + geometry.size += 2.0 * vec2(margin); + } var out = ShadowVarying(); - out.position = to_device_position(unit_vertex, shadow.bounds); + out.position = to_device_position(unit_vertex, geometry); out.color = hsla_to_rgba(shadow.color); out.shadow_id = instance_id; - out.clip_distances = distance_from_clip_rect(unit_vertex, shadow.bounds, shadow.content_mask); + out.clip_distances = distance_from_clip_rect(unit_vertex, geometry, shadow.content_mask); return out; } @@ -1000,21 +1013,36 @@ fn fs_shadow(input: ShadowVarying) -> @location(0) vec4 { let corner_radius = pick_corner_radius(center_to_point, shadow.corner_radii); - // The signal is only non-zero in a limited range, so don't waste samples - let low = center_to_point.y - half_size.y; - let high = center_to_point.y + half_size.y; - let start = clamp(-3.0 * shadow.blur_radius, low, high); - let end = clamp(3.0 * shadow.blur_radius, low, high); - - // Accumulate samples (we can get away with surprisingly few samples) - let step = (end - start) / 4.0; - var y = start + step * 0.5; - var alpha = 0.0; - for (var i = 0; i < 4; i += 1) { - let blur = blur_along_x(center_to_point.x, center_to_point.y - y, - shadow.blur_radius, corner_radius, half_size); - alpha += blur * gaussian(y, shadow.blur_radius) * step; - y += step; + var alpha: f32; + if (shadow.blur_radius == 0.0) { + let distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + let low = center_to_point.y - half_size.y; + let high = center_to_point.y + half_size.y; + let start = clamp(-3.0 * shadow.blur_radius, low, high); + let end = clamp(3.0 * shadow.blur_radius, low, high); + + // Accumulate samples (we can get away with surprisingly few samples) + let step = (end - start) / 4.0; + var y = start + step * 0.5; + alpha = 0.0; + for (var i = 0; i < 4; i += 1) { + let blur = blur_along_x(center_to_point.x, center_to_point.y - y, + shadow.blur_radius, corner_radius, half_size); + alpha += blur * gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = 1.0 - alpha; + let element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); } return blend_color(input.color, alpha); diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index fbc194e..bbfde3e 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -106,6 +106,13 @@ struct BackdropScratchBounds { pub struct WgpuSurfaceConfig { pub size: Size, pub transparent: bool, + /// Preferred presentation mode. When `Some`, the renderer will use this + /// mode if supported by the surface, falling back to `Fifo`. + /// When `None`, defaults to `Fifo` (VSync). + /// + /// Mobile platforms may prefer `Mailbox` (triple-buffering) to avoid + /// blocking in `get_current_texture()` during lifecycle transitions. + pub preferred_present_mode: Option, } struct WgpuPipelines { @@ -304,12 +311,18 @@ impl WgpuRenderer { opaque_alpha_mode }; + let present_mode = config + .preferred_present_mode + .filter(|mode| surface_caps.present_modes.contains(mode)) + .unwrap_or(wgpu::PresentMode::Fifo); + Self::new_with_surface( context, surface, surface_format, surface_caps.usages, alpha_mode, + present_mode, transparent_alpha_mode, opaque_alpha_mode, config, @@ -322,6 +335,7 @@ impl WgpuRenderer { surface_format: wgpu::TextureFormat, surface_usages: wgpu::TextureUsages, alpha_mode: wgpu::CompositeAlphaMode, + present_mode: wgpu::PresentMode, transparent_alpha_mode: wgpu::CompositeAlphaMode, opaque_alpha_mode: wgpu::CompositeAlphaMode, config: WgpuSurfaceConfig, @@ -355,7 +369,7 @@ impl WgpuRenderer { format: surface_format, width: clamped_width.max(1), height: clamped_height.max(1), - present_mode: wgpu::PresentMode::Fifo, + present_mode, desired_maximum_frame_latency: 2, alpha_mode, view_formats: vec![], diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 992a47d..f5a93e7 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -20,6 +20,8 @@ screen-capture = ["gpui/screen-capture", "scap"] gpui.workspace = true [target.'cfg(target_os = "windows")'.dependencies] +accesskit.workspace = true +accesskit_windows.workspace = true anyhow.workspace = true collections.workspace = true etagere = "0.2" diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 76d6c93..044c863 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -113,6 +113,7 @@ impl WindowsWindowInner { WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam), + WM_GETOBJECT => self.handle_wm_getobject(wparam, lparam), _ => None, }; if let Some(n) = handled { @@ -729,6 +730,17 @@ impl WindowsWindowInner { fn handle_activate_msg(self: &Rc, wparam: WPARAM) -> Option { let activated = wparam.loword() > 0; + + let events = self + .state + .a11y + .try_borrow_mut() + .ok() + .and_then(|mut a11y| a11y.as_mut()?.adapter.update_window_focus_state(activated)); + if let Some(events) = events { + events.raise(); + } + let this = self.clone(); if !activated { @@ -760,6 +772,20 @@ impl WindowsWindowInner { None } + fn handle_wm_getobject(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + let result = { + let mut a11y = self.state.a11y.borrow_mut(); + let a11y = a11y.as_mut()?; + a11y.adapter.handle_wm_getobject( + accesskit_windows::WPARAM(wparam.0), + accesskit_windows::LPARAM(lparam.0), + &mut a11y.activation_handler, + )? + }; + let lresult: accesskit_windows::LRESULT = result.into(); + Some(lresult.0) + } + fn handle_create_msg(&self, handle: HWND) -> Option { if self.hide_title_bar { notify_frame_changed(handle); diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index 621c45d..831333a 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -995,6 +995,10 @@ struct Shadow { Corners corner_radii; Bounds content_mask; Hsla color; + Bounds element_bounds; + Corners element_corner_radii; + uint inset; + uint pad; // align to 8 bytes }; struct ShadowVertexOutput { @@ -1016,10 +1020,16 @@ ShadowVertexOutput shadow_vertex(uint vertex_id: SV_VertexID, uint shadow_id: SV float2 unit_vertex = float2(float(vertex_id & 1u), 0.5 * float(vertex_id & 2u)); Shadow shadow = shadows[shadow_id]; - float margin = 3.0 * shadow.blur_radius; - Bounds bounds = shadow.bounds; - bounds.origin -= margin; - bounds.size += 2.0 * margin; + Bounds bounds; + if (shadow.inset != 0u) { + bounds = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + float margin = 3.0 * shadow.blur_radius; + bounds = shadow.bounds; + bounds.origin -= margin; + bounds.size += 2.0 * margin; + } float4 device_position = to_device_position(unit_vertex, bounds); float4 clip_distance = distance_from_clip_rect(unit_vertex, bounds, shadow.content_mask); @@ -1042,21 +1052,36 @@ float4 shadow_fragment(ShadowFragmentInput input): SV_TARGET { float2 point0 = input.position.xy - center; float corner_radius = pick_corner_radius(point0, shadow.corner_radii); - // The signal is only non-zero in a limited range, so don't waste samples - float low = point0.y - half_size.y; - float high = point0.y + half_size.y; - float start = clamp(-3. * shadow.blur_radius, low, high); - float end = clamp(3. * shadow.blur_radius, low, high); - - // Accumulate samples (we can get away with surprisingly few samples) - float step = (end - start) / 4.; - float y = start + step * 0.5; - float alpha = 0.; - for (int i = 0; i < 4; i++) { - alpha += blur_along_x(point0.x, point0.y - y, shadow.blur_radius, - corner_radius, half_size) * - gaussian(y, shadow.blur_radius) * step; - y += step; + float alpha; + if (shadow.blur_radius == 0.) { + float distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + float low = point0.y - half_size.y; + float high = point0.y + half_size.y; + float start = clamp(-3. * shadow.blur_radius, low, high); + float end = clamp(3. * shadow.blur_radius, low, high); + + // Accumulate samples (we can get away with surprisingly few samples) + float step = (end - start) / 4.; + float y = start + step * 0.5; + alpha = 0.; + for (int i = 0; i < 4; i++) { + alpha += blur_along_x(point0.x, point0.y - y, shadow.blur_radius, + corner_radius, half_size) * + gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = 1.0 - alpha; + float element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); } return input.color * float4(1., 1., 1., alpha); diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 35648ec..62acba3 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -80,6 +80,7 @@ pub struct WindowsWindowState { fullscreen: Cell>, initial_placement: Cell>, hwnd: HWND, + pub(crate) a11y: RefCell>, } pub(crate) struct WindowsWindowInner { @@ -172,6 +173,7 @@ impl WindowsWindowState { hwnd, invalidate_devices, direct_manipulation, + a11y: RefCell::new(None), }) } @@ -1110,6 +1112,64 @@ impl PlatformWindow for WindowsWindow { fn play_system_bell(&self) { let _ = unsafe { MessageBeep(MB_OK.0) }; } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let action_handler = A11yActionHandler(callbacks.action); + let is_focused = unsafe { GetForegroundWindow() } == self.0.hwnd; + + let adapter = accesskit_windows::Adapter::new( + accesskit_windows::HWND(self.0.hwnd.0), + is_focused, + action_handler, + ); + + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + + *self.state.a11y.borrow_mut() = Some(A11yState { + adapter, + activation_handler, + }); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let events = { + let mut a11y = self.state.a11y.borrow_mut(); + a11y.as_mut() + .and_then(|a11y| a11y.adapter.update_if_active(|| tree_update)) + }; + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // Windows UIA handles window bounds tracking automatically. + } +} + +pub(crate) struct A11yState { + pub(crate) adapter: accesskit_windows::Adapter, + pub(crate) activation_handler: A11yActivationHandler, +} + +pub(crate) struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } } #[implement(IDropTarget)] diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 684ab95..b6923f3 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -1,5 +1,7 @@ use crate::{Priority, RunnableMeta, Scheduler, SessionId, Timer}; +use async_task::Runnable; use std::{ + any::Any, future::Future, marker::PhantomData, mem::ManuallyDrop, @@ -12,18 +14,27 @@ use std::{ time::{Duration, Instant}, }; +/// A `!Send` executor pinned to a single session. Tasks spawned on it run in +/// order on whichever thread drains the dispatch destination supplied at +/// construction time. #[derive(Clone)] -pub struct ForegroundExecutor { +pub struct LocalExecutor { session_id: SessionId, scheduler: Arc, + dispatch: Arc) + Send + Sync>, not_send: PhantomData>, } -impl ForegroundExecutor { - pub fn new(session_id: SessionId, scheduler: Arc) -> Self { +impl LocalExecutor { + pub fn new( + session_id: SessionId, + scheduler: Arc, + dispatch: impl Fn(Runnable) + Send + Sync + 'static, + ) -> Self { Self { session_id, scheduler, + dispatch: Arc::new(dispatch), not_send: PhantomData, } } @@ -42,14 +53,11 @@ impl ForegroundExecutor { F: Future + 'static, F::Output: 'static, { - let session_id = self.session_id; - let scheduler = Arc::clone(&self.scheduler); + let dispatch = self.dispatch.clone(); let location = Location::caller(); let (runnable, task) = spawn_local_with_source_location( future, - move |runnable| { - scheduler.schedule_foreground(session_id, runnable); - }, + move |runnable| dispatch(runnable), RunnableMeta { location }, ); runnable.schedule(); @@ -108,6 +116,37 @@ impl ForegroundExecutor { pub fn now(&self) -> Instant { self.scheduler.clock().now() } + + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + #[track_caller] + pub fn spawn_dedicated(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } +} + +fn box_dedicated( + f: F, +) -> Box< + dyn FnOnce(LocalExecutor) -> Pin> + 'static>> + + Send + + 'static, +> +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, +{ + Box::new(move |executor| { + Box::pin(async move { Box::new(f(executor).await) as Box }) + }) } #[derive(Clone)] @@ -135,14 +174,16 @@ impl BackgroundExecutor { F: Future + Send + 'static, F::Output: Send + 'static, { - let scheduler = Arc::clone(&self.scheduler); + let scheduler = Arc::downgrade(&self.scheduler); let location = Location::caller(); let (runnable, task) = async_task::Builder::new() .metadata(RunnableMeta { location }) .spawn( move |_| future, move |runnable| { - scheduler.schedule_background_with_priority(runnable, priority); + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_background_with_priority(runnable, priority); + } }, ); runnable.schedule(); @@ -189,6 +230,20 @@ impl BackgroundExecutor { pub fn scheduler(&self) -> &Arc { &self.scheduler } + + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + #[track_caller] + pub fn spawn_dedicated(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } } /// Task is a primitive that allows work to happen in the background. @@ -198,16 +253,20 @@ impl BackgroundExecutor { /// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows /// the task to continue running, but with no way to return a value. #[must_use] -#[derive(Debug)] pub struct Task(TaskState); -#[derive(Debug)] enum TaskState { /// A task that is ready to return a value Ready(Option), /// A task that is currently running. Spawned(async_task::Task), + + /// A typed view of a [`Task>`]. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl Task { @@ -225,6 +284,7 @@ impl Task { match &self.0 { TaskState::Ready(_) => true, TaskState::Spawned(task) => task.is_finished(), + TaskState::Downcast { inner, .. } => inner.is_ready(), } } @@ -233,6 +293,7 @@ impl Task { match self { Task(TaskState::Ready(_)) => {} Task(TaskState::Spawned(task)) => task.detach(), + Task(TaskState::Downcast { inner, .. }) => inner.detach(), } } @@ -241,10 +302,36 @@ impl Task { FallibleTask(match self.0 { TaskState::Ready(val) => FallibleTaskState::Ready(val), TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()), + TaskState::Downcast { inner, .. } => FallibleTaskState::Downcast { + inner: Box::new(inner.fallible()), + marker: PhantomData, + }, }) } } +impl Task> { + /// Reinterprets the boxed output as a concrete `T` via downcast on completion. + pub fn downcast(self) -> Task { + Task(TaskState::Downcast { + inner: Box::new(self), + marker: PhantomData, + }) + } +} + +impl std::fmt::Debug for Task { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + TaskState::Ready(_) => f.debug_tuple("Task::Ready").finish(), + TaskState::Spawned(task) => f.debug_tuple("Task::Spawned").field(task).finish(), + TaskState::Downcast { inner, .. } => { + f.debug_tuple("Task::Downcast").field(inner).finish() + } + } + } +} + /// A task that returns `Option` instead of panicking when cancelled. #[must_use] pub struct FallibleTask(FallibleTaskState); @@ -255,6 +342,12 @@ enum FallibleTaskState { /// A task that is currently running (wraps async_task::FallibleTask). Spawned(async_task::FallibleTask), + + /// Mirror of [`TaskState::Downcast`] for fallible tasks. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl FallibleTask { @@ -268,17 +361,29 @@ impl FallibleTask { match self.0 { FallibleTaskState::Ready(_) => {} FallibleTaskState::Spawned(task) => task.detach(), + FallibleTaskState::Downcast { inner, .. } => inner.detach(), } } } -impl Future for FallibleTask { +impl Future for FallibleTask { type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { match unsafe { self.get_unchecked_mut() } { FallibleTask(FallibleTaskState::Ready(val)) => Poll::Ready(val.take()), FallibleTask(FallibleTaskState::Spawned(task)) => Pin::new(task).poll(cx), + FallibleTask(FallibleTaskState::Downcast { inner, .. }) => { + match Pin::new(inner.as_mut()).poll(cx) { + Poll::Ready(Some(boxed_any)) => Poll::Ready(Some( + *boxed_any + .downcast::() + .expect("FallibleTask::poll: downcast type mismatch"), + )), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } } } } @@ -290,17 +395,29 @@ impl std::fmt::Debug for FallibleTask { FallibleTaskState::Spawned(task) => { f.debug_tuple("FallibleTask::Spawned").field(task).finish() } + FallibleTaskState::Downcast { inner, .. } => f + .debug_tuple("FallibleTask::Downcast") + .field(inner) + .finish(), } } } -impl Future for Task { +impl Future for Task { type Output = T; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { match unsafe { self.get_unchecked_mut() } { Task(TaskState::Ready(val)) => Poll::Ready(val.take().unwrap()), Task(TaskState::Spawned(task)) => Pin::new(task).poll(cx), + Task(TaskState::Downcast { inner, .. }) => match Pin::new(inner.as_mut()).poll(cx) { + Poll::Ready(boxed_any) => Poll::Ready( + *boxed_any + .downcast::() + .expect("Task::poll: downcast type mismatch"), + ), + Poll::Pending => Poll::Pending, + }, } } } @@ -342,9 +459,10 @@ where "local task dropped by a thread that didn't spawn it. Task spawned at {}", self.location ); - unsafe { - ManuallyDrop::drop(&mut self.inner); - } + // SAFETY: `inner` is wrapped in `ManuallyDrop`, so this is the only + // place it is dropped. The thread check above ensures local futures + // are dropped on the thread that created them. + unsafe { ManuallyDrop::drop(&mut self.inner) }; } } @@ -352,27 +470,34 @@ where type Output = F::Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // SAFETY: We don't move any fields out of `self`; this mutable + // reference is only used to check metadata and to project the pin to + // `inner` below. + let this = unsafe { self.get_unchecked_mut() }; assert!( - self.id == thread_id(), + this.id == thread_id(), "local task polled by a thread that didn't spawn it. Task spawned at {}", - self.location + this.location ); - unsafe { self.map_unchecked_mut(|c| &mut *c.inner).poll(cx) } + // SAFETY: `inner` is structurally pinned by `Checked`; after + // `Checked` is pinned, `inner` is never moved. The thread check + // above ensures the local future is only polled by its spawning + // thread. + unsafe { Pin::new_unchecked(&mut *this.inner).poll(cx) } } } let location = metadata.location; - unsafe { - async_task::Builder::new() - .metadata(metadata) - .spawn_unchecked( - move |_| Checked { - id: thread_id(), - inner: ManuallyDrop::new(future), - location, - }, - schedule, - ) - } + let future = move |_| Checked { + id: thread_id(), + inner: ManuallyDrop::new(future), + location, + }; + + let builder = async_task::Builder::new().metadata(metadata); + // SAFETY: `Checked` enforces the invariants required by `spawn_unchecked`: + // the non-`Send` future is only polled and dropped on the thread that + // spawned it. + unsafe { builder.spawn_unchecked(future, schedule) } } diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 05d285d..371000c 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -11,11 +11,13 @@ pub use test_scheduler::*; use async_task::Runnable; use futures::channel::oneshot; use std::{ + any::Any, future::Future, panic::Location, pin::Pin, sync::Arc, task::{Context, Poll}, + thread, time::Duration, }; @@ -82,7 +84,8 @@ pub trait Scheduler: Send + Sync { timeout: Option, ) -> bool; - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable); + /// Schedule a runnable on the local (session-pinned) queue for `session_id`. + fn schedule_local(&self, session_id: SessionId, runnable: Runnable); /// Schedule a background task with the given priority. fn schedule_background_with_priority( @@ -103,11 +106,62 @@ pub trait Scheduler: Send + Sync { fn timer(&self, timeout: Duration) -> Timer; fn clock(&self) -> Arc; + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task>; + fn as_test(&self) -> Option<&TestScheduler> { None } } +/// Spawn work on a fresh OS thread that's exclusive to the returned task and +/// anything spawned on the executor it provides. +pub fn spawn_dedicated_thread( + session_id: SessionId, + scheduler: Arc, + f: F, +) -> Task +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + 'static, +{ + let (runnable_sender, runnable_receiver) = flume::unbounded::>(); + let (task_sender, task_receiver) = flume::bounded::>(1); + + thread::Builder::new() + .name(format!("spawn_dedicated session {:?}", session_id)) + .spawn(move || { + let dispatch = move |runnable: Runnable| { + let _ = runnable_sender.send(runnable); + }; + let executor = LocalExecutor::new(session_id, scheduler, dispatch); + let root_task = executor.spawn(f(executor.clone())); + let _ = task_sender.send(root_task); + // Close the dispatch sender so the dedicated thread exits after draining queued work. + drop(executor); + + while let Ok(runnable) = runnable_receiver.recv() { + runnable.run(); + } + }) + .expect("failed to spawn dedicated thread"); + + task_receiver + .recv() + .expect("dedicated thread failed to produce root task") +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct SessionId(u16); diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 70dc01e..4efc65c 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -1,5 +1,5 @@ use crate::{ - BackgroundExecutor, Clock, ForegroundExecutor, Priority, RunnableMeta, Scheduler, SessionId, + BackgroundExecutor, Clock, LocalExecutor, Priority, RunnableMeta, Scheduler, SessionId, Task, TestClock, Timer, }; use async_task::Runnable; @@ -11,7 +11,7 @@ use rand::{ prelude::*, }; use std::{ - any::type_name_of_val, + any::{Any, type_name_of_val}, collections::{BTreeMap, HashSet, VecDeque}, env, fmt::Write, @@ -57,10 +57,14 @@ impl TestScheduler { .map(|seed| seed.parse().unwrap()) .unwrap_or(0); + let interactive = std::env::var("SCHEDULER_NONINTERACTIVE").is_err(); + (seed..seed + num_iterations as u64) .map(|seed| { let mut unwind_safe_f = AssertUnwindSafe(&mut f); - eprintln!("Running seed: {seed}"); + if interactive { + eprintln!("Running seed: {seed}"); + } match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) { Ok(result) => result, Err(error) => { @@ -108,7 +112,12 @@ impl TestScheduler { pub fn end_test(&self) { let mut state = self.state.lock(); if let Some((message, backtrace)) = &state.non_determinism_error { - panic!("{}\n{:?}", message, backtrace) + if cfg!(miri) { + // miri cannot debug print backtraces with `miri-disable-isolation` enabled + panic!("{}", message) + } else { + panic!("{}\n{:?}", message, backtrace) + } } state.finished = true; } @@ -143,18 +152,21 @@ impl TestScheduler { self.state.lock().is_main_thread } - /// Allocate a new session ID for foreground task scheduling. - /// This is used by GPUI's TestDispatcher to map dispatcher instances to sessions. pub fn allocate_session_id(&self) -> SessionId { let mut state = self.state.lock(); state.next_session_id.0 += 1; state.next_session_id } - /// Create a foreground executor for this scheduler - pub fn foreground(self: &Arc) -> ForegroundExecutor { + /// Create a local executor for this scheduler. + pub fn foreground(self: &Arc) -> LocalExecutor { let session_id = self.allocate_session_id(); - ForegroundExecutor::new(session_id, self.clone()) + let scheduler = Arc::downgrade(self); + LocalExecutor::new(session_id, self.clone(), move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) } /// Create a background executor for this scheduler @@ -436,6 +448,9 @@ impl TestScheduler { } } else if deadline.is_some() { false + } else if cfg!(miri) { + // miri cannot debug print backtraces with `miri-disable-isolation` enabled + panic!("Parking forbidden."); } else if self.state.lock().capture_pending_traces { let mut pending_traces = String::new(); for (_, trace) in mem::take(&mut self.state.lock().pending_traces) { @@ -551,7 +566,7 @@ impl Scheduler for TestScheduler { completed } - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable) { + fn schedule_local(&self, session_id: SessionId, runnable: Runnable) { assert_correct_thread(&self.thread, &self.state); let mut state = self.state.lock(); let ix = if state.randomize_order { @@ -626,6 +641,27 @@ impl Scheduler for TestScheduler { self.clock.clone() } + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + let session_id = self.allocate_session_id(); + let scheduler = Arc::downgrade(&self); + let executor = LocalExecutor::new(session_id, self, move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }); + executor.spawn(f(executor.clone())) + } + fn as_test(&self) -> Option<&TestScheduler> { Some(self) } diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index dc24fed..a6a5408 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -34,11 +34,49 @@ fn test_background_executor_spawn() { }); } +#[test] +fn test_scheduler_drops_with_stalled_detached_foreground_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + let (sender, receiver) = oneshot::channel::<()>(); + + scheduler + .foreground() + .spawn(async move { + receiver.await.ok(); + }) + .detach(); + scheduler.run(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); + drop(sender); +} + +#[test] +fn test_scheduler_drops_with_stalled_detached_background_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + let (sender, receiver) = oneshot::channel::<()>(); + + scheduler + .background() + .spawn(async move { + receiver.await.ok(); + }) + .detach(); + scheduler.run(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); + drop(sender); +} + #[test] fn test_foreground_ordering() { let mut traces = HashSet::new(); - TestScheduler::many(100, async |scheduler| { + TestScheduler::many(if cfg!(miri) { 5 } else { 100 }, async |scheduler| { #[derive(Hash, PartialEq, Eq)] struct TraceEntry { session: usize, @@ -128,6 +166,24 @@ fn test_timer_ordering() { }); } +#[test] +fn test_foreground_task_can_hold_mut_borrow_across_await() { + TestScheduler::once(async |scheduler| { + let foreground = scheduler.foreground(); + let (sender, mut receiver) = mpsc::unbounded::<()>(); + + foreground + .spawn(async move { + receiver.next().await; + }) + .detach(); + + scheduler.run(); + sender.unbounded_send(()).unwrap(); + scheduler.run(); + }); +} + #[test] fn test_send_from_bg_to_fg() { TestScheduler::once(async |scheduler| { @@ -238,7 +294,7 @@ fn test_block() { } #[test] -#[should_panic(expected = "Parking forbidden. Pending traces:")] +#[should_panic(expected = "Parking forbidden.")] fn test_parking_panics() { let config = TestSchedulerConfig { capture_pending_traces: true, @@ -315,7 +371,7 @@ fn test_block_with_timeout() { // Test case: future makes progress via timer but still times out let mut results = BTreeSet::new(); - TestScheduler::many(100, async |scheduler| { + TestScheduler::many(if cfg!(miri) { 5 } else { 100 }, async |scheduler| { // Keep the existing probabilistic behavior here (do not force 0 ticks), since this subtest // is explicitly checking that some seeds/timeouts can complete while others can time out. let task = scheduler.background().spawn(async move { @@ -329,7 +385,11 @@ fn test_block_with_timeout() { }); assert_eq!( results.into_iter().collect::>(), - vec![None, Some(42)] + if cfg!(miri) { + vec![Some(42)] + } else { + vec![None, Some(42)] + } ); // Regression test: @@ -375,7 +435,7 @@ fn test_block_with_timeout() { #[test] fn test_block_does_not_progress_same_session_foreground() { let mut task2_made_progress_once = false; - TestScheduler::many(1000, async |scheduler| { + TestScheduler::many(if cfg!(miri) { 5 } else { 1000 }, async |scheduler| { let foreground1 = scheduler.foreground(); let foreground2 = scheduler.foreground(); @@ -589,7 +649,7 @@ fn test_background_priority_scheduling() { // Run many iterations to get statistical significance let mut high_before_low_count = 0; - let iterations = 100; + let iterations = if cfg!(miri) { 5 } else { 100 }; for seed in 0..iterations { let config = TestSchedulerConfig::with_seed(seed); @@ -643,3 +703,209 @@ fn test_background_priority_scheduling() { iterations ); } + +#[test] +fn test_spawn_dedicated_basic_round_trip() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async { 42 }) + .await + }); + assert_eq!(result, 42); +} + +#[test] +fn test_spawn_dedicated_not_send_future() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async move { + let state = Rc::new(RefCell::new(0_i32)); + for _ in 0..5 { + *state.borrow_mut() += 1; + } + *state.borrow() + }) + .await + }); + assert_eq!(result, 5); +} + +#[test] +fn test_spawn_dedicated_send_closure_captures() { + use parking_lot::Mutex; + + let observed = TestScheduler::once(async |scheduler| { + let shared = Arc::new(Mutex::new(0_i32)); + let shared_for_closure = shared.clone(); + let returned = scheduler + .background() + .spawn_dedicated(move |_executor| { + let local = shared_for_closure; + async move { + *local.lock() = 7; + } + }) + .await; + let _: () = returned; + *shared.lock() + }); + assert_eq!(observed, 7); +} + +#[test] +fn test_spawn_dedicated_inner_spawn_local() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|executor| async move { + let inner = Rc::new(RefCell::new(0_i32)); + let inner_for_child = inner.clone(); + let child = executor.spawn(async move { + *inner_for_child.borrow_mut() = 99; + *inner_for_child.borrow() + }); + child.await + }) + .await + }); + assert_eq!(result, 99); +} + +#[test] +fn test_spawn_dedicated_determinism_under_many() { + use parking_lot::Mutex; + + let outcomes = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| { + let trace = Arc::new(Mutex::new(Vec::::new())); + + let background = scheduler.background(); + let mut tasks = Vec::new(); + for id in 0..4_u32 { + let trace = trace.clone(); + let task = background.spawn_dedicated(move |executor| async move { + for step in 0..3 { + trace.lock().push(id * 100 + step); + executor.spawn(async {}).await; + } + id + }); + tasks.push(task); + } + + let mut outputs = Vec::new(); + for task in tasks { + outputs.push(task.await); + } + + (trace.lock().clone(), outputs) + }); + + let outcomes_replay = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| { + let trace = Arc::new(Mutex::new(Vec::::new())); + + let background = scheduler.background(); + let mut tasks = Vec::new(); + for id in 0..4_u32 { + let trace = trace.clone(); + let task = background.spawn_dedicated(move |executor| async move { + for step in 0..3 { + trace.lock().push(id * 100 + step); + executor.spawn(async {}).await; + } + id + }); + tasks.push(task); + } + + let mut outputs = Vec::new(); + for task in tasks { + outputs.push(task.await); + } + + (trace.lock().clone(), outputs) + }); + + assert_eq!( + outcomes, outcomes_replay, + "per-seed outcomes should be reproducible" + ); + + let any_interleaved = outcomes.iter().any(|(trace, _)| { + trace + .windows(2) + .any(|window| window[0] / 100 != window[1] / 100) + }); + assert!( + any_interleaved, + "expected at least one seed to interleave dedicated tasks" + ); +} + +#[test] +fn test_spawn_dedicated_dropping_task_cancels_future() { + use parking_lot::Mutex; + + let counter_after = TestScheduler::once(async |scheduler| { + let counter = Arc::new(Mutex::new(0_u32)); + let (resume_tx, resume_rx) = oneshot::channel::<()>(); + + let task = { + let counter = counter.clone(); + scheduler + .background() + .spawn_dedicated(move |_executor| async move { + *counter.lock() = 1; + let _ = resume_rx.await; + *counter.lock() = 2; + }) + }; + + scheduler.run(); + assert_eq!(*counter.lock(), 1); + + drop(task); + let _ = resume_tx.send(()); + scheduler.run(); + + *counter.lock() + }); + + assert_eq!( + counter_after, 1, + "dropping the dedicated task must cancel the root future before its second write" + ); +} + +#[test] +fn test_spawn_dedicated_detached_child_runs_after_root_completes() { + use parking_lot::Mutex; + + let child_ran = TestScheduler::once(async |scheduler| { + let child_ran = Arc::new(Mutex::new(false)); + + let task = { + let child_ran = child_ran.clone(); + scheduler + .background() + .spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + *child_ran.lock() = true; + }) + .detach(); + }) + }; + + task.await; + scheduler.run(); + + *child_ran.lock() + }); + + assert!( + child_ran, + "detached child must complete after the root, not be cancelled with it" + ); +} diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index 0882992..797b308 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -1349,7 +1349,7 @@ mod tests { use rand::{distr::StandardUniform, prelude::*}; use std::cmp; - #[ctor::ctor] + #[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } diff --git a/crates/util/src/command/darwin.rs b/crates/util/src/command/darwin.rs index 347fc81..5847156 100644 --- a/crates/util/src/command/darwin.rs +++ b/crates/util/src/command/darwin.rs @@ -516,15 +516,42 @@ fn spawn_posix_spawn( fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> { let mut fds: [libc::c_int; 2] = [0; 2]; - let result = unsafe { libc::pipe(fds.as_mut_ptr()) }; - if result == -1 { - return Err(io::Error::last_os_error()); + unsafe { + let result = libc::pipe(fds.as_mut_ptr()); + if result == -1 { + let error = io::Error::last_os_error(); + return Err(error); + } + + // Set close-on-exec on both ends of the pipe. + // + // Without this, unrelated spawns elsewhere in the process (e.g. + // `smol::process` or `async_process`, which on Apple platforms use + // `posix_spawn` *without* `POSIX_SPAWN_CLOEXEC_DEFAULT`) would inherit + // these file descriptors and keep the pipes open even after we drop our + // side. + for &fd in &fds { + let result = libc::ioctl(fd, libc::FIOCLEX); + if result == -1 { + let error = io::Error::last_os_error(); + libc::close(fds[0]); + libc::close(fds[1]); + return Err(error); + } + } + + Ok((fds[0], fds[1])) } - Ok((fds[0], fds[1])) } fn open_dev_null(flags: libc::c_int) -> io::Result { - let fd = unsafe { libc::open(c"/dev/null".as_ptr() as *const libc::c_char, flags) }; + // Set close-on-exec for this pipe, for the same reason as in `create_pipe`. + let fd = unsafe { + libc::open( + c"/dev/null".as_ptr() as *const libc::c_char, + flags | libc::O_CLOEXEC, + ) + }; if fd == -1 { return Err(io::Error::last_os_error()); } @@ -553,6 +580,49 @@ mod tests { use super::*; use futures_lite::AsyncWriteExt; + // Verifies that pipes returned by `create_pipe` aren't visible to unrelated + // child processes spawned via `std::process::Command`. On macOS, `std` + // uses `posix_spawn` without `POSIX_SPAWN_CLOEXEC_DEFAULT`, so any + // non-CLOEXEC fd in the parent leaks into the child. Without + // `FD_CLOEXEC` on our pipe fds, an unrelated spawn (a terminal, the crash + // handler, etc.) running concurrently with a piped git child would hold + // git's stdin write end open and deadlock the git child on `read()`. + #[test] + fn test_create_pipe_not_inherited_by_unrelated_spawn() { + let (read_fd, write_fd) = create_pipe().expect("create_pipe failed"); + + // Probe with the exact fds returned by `create_pipe` (no dup), since + // duping with `F_DUPFD` would lose CLOEXEC and `F_DUPFD_CLOEXEC` would + // unconditionally set it, either of which would defeat the test. + #[allow(clippy::disallowed_methods)] + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(format!( + "for fd in {read_fd} {write_fd}; do \ + if [ -e /dev/fd/$fd ]; then \ + echo $fd WAS INHERITED; \ + else \ + echo $fd WAS NOT INHERITED; \ + fi; \ + done; \ + echo DONE" + )) + .output() + .expect("failed to spawn sh"); + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + + unsafe { + libc::close(read_fd); + libc::close(write_fd); + } + + assert_eq!( + stdout, + format!("{read_fd} WAS NOT INHERITED\n{write_fd} WAS NOT INHERITED\nDONE\n") + ); + } + #[test] fn test_spawn_echo() { smol::block_on(async { From 94450c3e1a709cb9e9e9bab76d5162e3cae30d7c Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 09:32:55 -0700 Subject: [PATCH 07/22] fix: resolve upstream sync follow-ups --- Cargo.lock | 26 +- Cargo.toml | 1 + crates/gpui/Cargo.toml | 1 + crates/gpui/src/elements/div.rs | 114 ++++--- crates/gpui/src/text_system/line_wrapper.rs | 62 +++- crates/gpui_linux/Cargo.toml | 2 + crates/gpui_linux/src/linux.rs | 2 + .../gpui_linux/src/linux/accesskit_shims.rs | 29 ++ crates/gpui_linux/src/linux/wayland/window.rs | 31 +- crates/gpui_linux/src/linux/x11/window.rs | 31 +- crates/gpui_macos/src/platform.rs | 2 +- crates/gpui_macos/src/window.rs | 292 +++++++++--------- 12 files changed, 333 insertions(+), 260 deletions(-) create mode 100644 crates/gpui_linux/src/linux/accesskit_shims.rs diff --git a/Cargo.lock b/Cargo.lock index c542c54..ba39565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,7 +1688,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1891,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2490,7 +2490,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.17", - "windows 0.57.0", + "windows 0.62.2", ] [[package]] @@ -2592,6 +2592,7 @@ dependencies = [ "taffy", "thiserror 2.0.17", "ttf-parser", + "unicode-general-category", "unicode-segmentation", "usvg", "util", @@ -2646,6 +2647,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", + "wgpu", "x11-clipboard", "x11rb", "xkbcommon", @@ -3122,7 +3124,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -3379,7 +3381,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4071,7 +4073,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5445,7 +5447,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6352,7 +6354,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6832,6 +6834,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -7544,7 +7552,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 29c258f..e2d3873 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -170,6 +170,7 @@ thiserror = "2.0.12" tokio = { version = "1" } tracing = "0.1.40" unicase = "2.6" +unicode-general-category = "1.1.0" unicode-segmentation = "1.10" url = "2.2" uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index db18208..5359eaa 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -71,6 +71,7 @@ raw-window-handle = "0.6" refineable.workspace = true regex.workspace = true scheduler.workspace = true +unicode-general-category.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ "text", "system-fonts", diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index fe1ba5a..6f24b0c 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1126,97 +1126,97 @@ pub trait StatefulInteractiveElement: InteractiveElement { role != accesskit::Role::GenericContainer, "GenericContainer is filtered out of the a11y tree and has no effect" ); - self.interactivity().override_role = Some(role); + self.interactivity().a11y_state_mut().override_role = Some(role); self } /// Set the accessible label for this element. fn aria_label(mut self, label: impl Into) -> Self { - self.interactivity().aria_label = Some(label.into()); + self.interactivity().a11y_state_mut().aria_label = Some(label.into()); self } /// Set the selected state for this element. fn aria_selected(mut self, selected: bool) -> Self { - self.interactivity().aria_selected = Some(selected); + self.interactivity().a11y_state_mut().aria_selected = Some(selected); self } /// Set the expanded state for this element. fn aria_expanded(mut self, expanded: bool) -> Self { - self.interactivity().aria_expanded = Some(expanded); + self.interactivity().a11y_state_mut().aria_expanded = Some(expanded); self } /// Set the toggled state for this element. fn aria_toggled(mut self, toggled: accesskit::Toggled) -> Self { - self.interactivity().aria_toggled = Some(toggled); + self.interactivity().a11y_state_mut().aria_toggled = Some(toggled); self } /// Set the numeric value for this element. fn aria_numeric_value(mut self, value: f64) -> Self { - self.interactivity().aria_numeric_value = Some(value); + self.interactivity().a11y_state_mut().aria_numeric_value = Some(value); self } /// Set the minimum numeric value for this element. fn aria_min_numeric_value(mut self, value: f64) -> Self { - self.interactivity().aria_min_numeric_value = Some(value); + self.interactivity().a11y_state_mut().aria_min_numeric_value = Some(value); self } /// Set the maximum numeric value for this element. fn aria_max_numeric_value(mut self, value: f64) -> Self { - self.interactivity().aria_max_numeric_value = Some(value); + self.interactivity().a11y_state_mut().aria_max_numeric_value = Some(value); self } /// Set the orientation of this element. fn aria_orientation(mut self, orientation: accesskit::Orientation) -> Self { - self.interactivity().aria_orientation = Some(orientation); + self.interactivity().a11y_state_mut().aria_orientation = Some(orientation); self } /// Set the heading level of this element. fn aria_level(mut self, level: usize) -> Self { - self.interactivity().aria_level = Some(level); + self.interactivity().a11y_state_mut().aria_level = Some(level); self } /// Set the position in set of this element. fn aria_position_in_set(mut self, position: usize) -> Self { - self.interactivity().aria_position_in_set = Some(position); + self.interactivity().a11y_state_mut().aria_position_in_set = Some(position); self } /// Set the size of set for this element. fn aria_size_of_set(mut self, size: usize) -> Self { - self.interactivity().aria_size_of_set = Some(size); + self.interactivity().a11y_state_mut().aria_size_of_set = Some(size); self } /// Set the row index for this element. fn aria_row_index(mut self, index: usize) -> Self { - self.interactivity().aria_row_index = Some(index); + self.interactivity().a11y_state_mut().aria_row_index = Some(index); self } /// Set the column index for this element. fn aria_column_index(mut self, index: usize) -> Self { - self.interactivity().aria_column_index = Some(index); + self.interactivity().a11y_state_mut().aria_column_index = Some(index); self } /// Set the row count for this element. fn aria_row_count(mut self, count: usize) -> Self { - self.interactivity().aria_row_count = Some(count); + self.interactivity().a11y_state_mut().aria_row_count = Some(count); self } /// Set the column count for this element. fn aria_column_count(mut self, count: usize) -> Self { - self.interactivity().aria_column_count = Some(count); + self.interactivity().a11y_state_mut().aria_column_count = Some(count); self } @@ -1227,7 +1227,8 @@ pub trait StatefulInteractiveElement: InteractiveElement { listener: impl FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static, ) -> Self { self.interactivity() - .a11y_action_listeners + .a11y_state_mut() + .action_listeners .push((action, Box::new(listener))); self } @@ -1539,7 +1540,9 @@ impl Element for Div { fn a11y_role(&self) -> Option { self.interactivity - .override_role + .a11y_state + .as_deref() + .and_then(|state| state.override_role) .filter(|role| *role != accesskit::Role::GenericContainer) } @@ -1782,24 +1785,7 @@ pub struct Interactivity { pub(crate) tab_index: Option, pub(crate) tab_group: bool, pub(crate) tab_stop: bool, - pub(crate) a11y_action_listeners: - Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>, - pub(crate) override_role: Option, - pub(crate) aria_label: Option, - pub(crate) aria_selected: Option, - pub(crate) aria_expanded: Option, - pub(crate) aria_toggled: Option, - pub(crate) aria_numeric_value: Option, - pub(crate) aria_min_numeric_value: Option, - pub(crate) aria_max_numeric_value: Option, - pub(crate) aria_orientation: Option, - pub(crate) aria_level: Option, - pub(crate) aria_position_in_set: Option, - pub(crate) aria_size_of_set: Option, - pub(crate) aria_row_index: Option, - pub(crate) aria_column_index: Option, - pub(crate) aria_row_count: Option, - pub(crate) aria_column_count: Option, + pub(crate) a11y_state: Option>, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -1808,7 +1794,34 @@ pub struct Interactivity { pub(crate) debug_selector: Option, } +#[derive(Default)] +pub(crate) struct A11yState { + action_listeners: Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>, + override_role: Option, + aria_label: Option, + aria_selected: Option, + aria_expanded: Option, + aria_toggled: Option, + aria_numeric_value: Option, + aria_min_numeric_value: Option, + aria_max_numeric_value: Option, + aria_orientation: Option, + aria_level: Option, + aria_position_in_set: Option, + aria_size_of_set: Option, + aria_row_index: Option, + aria_column_index: Option, + aria_row_count: Option, + aria_column_count: Option, +} + impl Interactivity { + fn a11y_state_mut(&mut self) -> &mut A11yState { + self.a11y_state + .get_or_insert_with(Box::::default) + .as_mut() + } + /// Layout this element according to this interactivity state's configured styles pub fn request_layout( &mut self, @@ -2218,9 +2231,12 @@ impl Interactivity { None }; if let Some(node_id) = current_a11y_node_id { - if !self.a11y_action_listeners.is_empty() { + if let Some(a11y_state) = + self.a11y_state.as_mut() + && !a11y_state.action_listeners.is_empty() + { for (action, listener) in - self.a11y_action_listeners.drain(..) + a11y_state.action_listeners.drain(..) { window.on_a11y_action( node_id, action, listener, @@ -3025,6 +3041,20 @@ impl Interactivity { } pub(crate) fn write_a11y_info(&self, node: &mut accesskit::Node) { + if let Some(a11y_state) = self.a11y_state.as_deref() { + a11y_state.write_a11y_info(node); + } + if !self.click_listeners.is_empty() { + node.add_action(accesskit::Action::Click); + } + if self.tracked_focus_handle.is_some() || self.focusable { + node.add_action(accesskit::Action::Focus); + } + } +} + +impl A11yState { + fn write_a11y_info(&self, node: &mut accesskit::Node) { if let Some(label) = &self.aria_label { node.set_label(label.to_string()); } @@ -3070,13 +3100,7 @@ impl Interactivity { if let Some(count) = self.aria_column_count { node.set_column_count(count); } - if !self.click_listeners.is_empty() { - node.add_action(accesskit::Action::Click); - } - if self.tracked_focus_handle.is_some() || self.focusable { - node.add_action(accesskit::Action::Focus); - } - for (action, _) in &self.a11y_action_listeners { + for (action, _) in &self.action_listeners { node.add_action(*action); } } diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index c147e77..993aaab 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -1,6 +1,7 @@ use crate::{FontId, Pixels, SharedString, TextRun, TextSystem, px}; use collections::HashMap; use std::{borrow::Cow, iter, sync::Arc}; +use unicode_general_category::{GeneralCategory, get_general_category}; /// Determines whether to truncate text from the start or end. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -585,10 +586,23 @@ fn trim_end_before_truncation_affix<'a>(text: &'a str, truncation_affix: &str) - if truncation_affix.is_empty() { text } else { - text.trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation()) + text.trim_end_matches(|c: char| c.is_whitespace() || is_truncation_boundary_punctuation(c)) } } +fn is_truncation_boundary_punctuation(c: char) -> bool { + matches!( + get_general_category(c), + GeneralCategory::ConnectorPunctuation + | GeneralCategory::DashPunctuation + | GeneralCategory::OpenPunctuation + | GeneralCategory::ClosePunctuation + | GeneralCategory::InitialPunctuation + | GeneralCategory::FinalPunctuation + | GeneralCategory::OtherPunctuation + ) +} + fn runs_for_prefix(runs: &[TextRun], prefix_len: usize) -> Vec { let mut prefix_runs = Vec::new(); let mut remaining_len = prefix_len; @@ -968,6 +982,52 @@ mod tests { perform_test(&mut wrapper, "hello, world", "hello, ", "", px(70.)); } + #[test] + fn test_truncate_line_end_trims_unicode_punctuation_before_affix() { + let mut wrapper = build_wrapper(); + + for text in ["hello— world", "hello، world"] { + let dummy_runs = generate_test_runs(&[text.len()]); + let line_width = text + .chars() + .take(6) + .chain("…".chars()) + .map(|c| wrapper.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width) + + px(1.); + + let (result, result_runs) = + wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End); + + assert_eq!(result, "hello…"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + } + } + + #[test] + fn test_truncate_line_end_preserves_unicode_punctuation_without_affix() { + let mut wrapper = build_wrapper(); + let text = "hello— world"; + let dummy_runs = generate_test_runs(&[text.len()]); + let line_width = "hello—" + .chars() + .map(|c| wrapper.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width) + + px(1.); + + let (result, result_runs) = + wrapper.truncate_line(text.into(), line_width, "", &dummy_runs, TruncateFrom::End); + + assert_eq!(result, "hello—"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + } + #[test] fn test_truncate_line_start() { let mut wrapper = build_wrapper(); diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 1e73f1a..3dc0bf2 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -28,6 +28,7 @@ wayland = [ "wayland-protocols-plasma", "wayland-protocols-wlr", "filedescriptor", + "wgpu", "xkbcommon", "open", "gpui/wayland", @@ -93,6 +94,7 @@ font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "11052312 bitflags = { workspace = true, optional = true } filedescriptor = { version = "0.8.2", optional = true } open = { version = "5.2.0", optional = true } +wgpu = { workspace = true, optional = true } xkbcommon = { version = "0.8.0", features = ["wayland", "x11"], optional = true } # Screen capture diff --git a/crates/gpui_linux/src/linux.rs b/crates/gpui_linux/src/linux.rs index bafdc2e..a112393 100644 --- a/crates/gpui_linux/src/linux.rs +++ b/crates/gpui_linux/src/linux.rs @@ -1,3 +1,5 @@ +#[cfg(any(feature = "wayland", feature = "x11"))] +mod accesskit_shims; mod dispatcher; mod headless; mod keyboard; diff --git a/crates/gpui_linux/src/linux/accesskit_shims.rs b/crates/gpui_linux/src/linux/accesskit_shims.rs new file mode 100644 index 0000000..11afc48 --- /dev/null +++ b/crates/gpui_linux/src/linux/accesskit_shims.rs @@ -0,0 +1,29 @@ +pub(crate) struct TrivialActivationHandler { + pub(crate) callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +pub(crate) struct TrivialActionHandler( + pub(crate) Box, +); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +pub(crate) struct TrivialDeactivationHandler { + pub(crate) callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } +} diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 1741bc4..e1de6c8 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -27,6 +27,9 @@ use wayland_protocols::{ use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; +use crate::linux::accesskit_shims::{ + TrivialActionHandler, TrivialActivationHandler, TrivialDeactivationHandler, +}; use crate::linux::wayland::{display::WaylandDisplay, serial::SerialKind}; use crate::linux::{Globals, Output, WaylandClientStatePtr, get_window}; use gpui::{ @@ -1502,34 +1505,6 @@ impl PlatformWindow for WaylandWindow { } } -struct TrivialActivationHandler { - callback: Box Option + Send + 'static>, -} - -impl accesskit::ActivationHandler for TrivialActivationHandler { - fn request_initial_tree(&mut self) -> Option { - (self.callback)() - } -} - -struct TrivialActionHandler(Box); - -impl accesskit::ActionHandler for TrivialActionHandler { - fn do_action(&mut self, request: accesskit::ActionRequest) { - (self.0)(request); - } -} - -struct TrivialDeactivationHandler { - callback: Box, -} - -impl accesskit::DeactivationHandler for TrivialDeactivationHandler { - fn deactivate_accessibility(&mut self) { - (self.callback)(); - } -} - fn update_window(mut state: RefMut) { let opaque = !state.is_transparent(); diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 3b2cade..0ea7665 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -2,6 +2,9 @@ use anyhow::{Context as _, anyhow}; use x11rb::connection::RequestConnection; use crate::linux::X11ClientStatePtr; +use crate::linux::accesskit_shims::{ + TrivialActionHandler, TrivialActivationHandler, TrivialDeactivationHandler, +}; use gpui::{ AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers, OverlayInputMode, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, @@ -1921,31 +1924,3 @@ impl PlatformWindow for X11Window { } } } - -struct TrivialActivationHandler { - callback: Box Option + Send + 'static>, -} - -impl accesskit::ActivationHandler for TrivialActivationHandler { - fn request_initial_tree(&mut self) -> Option { - (self.callback)() - } -} - -struct TrivialActionHandler(Box); - -impl accesskit::ActionHandler for TrivialActionHandler { - fn do_action(&mut self, request: accesskit::ActionRequest) { - (self.0)(request); - } -} - -struct TrivialDeactivationHandler { - callback: Box, -} - -impl accesskit::DeactivationHandler for TrivialDeactivationHandler { - fn deactivate_accessibility(&mut self) { - (self.callback)(); - } -} diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 05d47ed..f00882c 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -77,7 +77,7 @@ unsafe fn build_classes() { } }; unsafe { - APP_DELEGATE_CLASS = unsafe { + APP_DELEGATE_CLASS = { let mut decl = ClassDecl::new("GPUIApplicationDelegate", class!(NSResponder)).unwrap(); decl.add_ivar::<*mut c_void>(MAC_PLATFORM_IVAR); decl.add_method( diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 0ce4c2c..45358d8 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -129,165 +129,161 @@ unsafe fn build_classes() { VIEW_CLASS = { let mut decl = ClassDecl::new("GPUIView", class!(NSView)).unwrap(); decl.add_ivar::<*mut c_void>(WINDOW_STATE_IVAR); - unsafe { - decl.add_method(sel!(dealloc), dealloc_view as extern "C" fn(&Object, Sel)); + decl.add_method(sel!(dealloc), dealloc_view as extern "C" fn(&Object, Sel)); - decl.add_method( - sel!(performKeyEquivalent:), - handle_key_equivalent as extern "C" fn(&Object, Sel, id) -> BOOL, - ); - decl.add_method( - sel!(keyDown:), - handle_key_down as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(keyUp:), - handle_key_up as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(rightMouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(rightMouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(otherMouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(otherMouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseMoved:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(pressureChangeWithEvent:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseExited:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseDragged:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(scrollWheel:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(swipeWithEvent:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(flagsChanged:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); + decl.add_method( + sel!(performKeyEquivalent:), + handle_key_equivalent as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + decl.add_method( + sel!(keyDown:), + handle_key_down as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(keyUp:), + handle_key_up as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(rightMouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(rightMouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(otherMouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(otherMouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseMoved:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(pressureChangeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseExited:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseDragged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(scrollWheel:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(swipeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(flagsChanged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); - decl.add_method( - sel!(makeBackingLayer), - make_backing_layer as extern "C" fn(&Object, Sel) -> id, - ); + decl.add_method( + sel!(makeBackingLayer), + make_backing_layer as extern "C" fn(&Object, Sel) -> id, + ); - decl.add_protocol(Protocol::get("CALayerDelegate").unwrap()); - decl.add_method( - sel!(viewDidChangeBackingProperties), - view_did_change_backing_properties as extern "C" fn(&Object, Sel), - ); - decl.add_method( - sel!(setFrameSize:), - set_frame_size as extern "C" fn(&Object, Sel, NSSize), - ); - decl.add_method( - sel!(displayLayer:), - display_layer as extern "C" fn(&Object, Sel, id), - ); + decl.add_protocol(Protocol::get("CALayerDelegate").unwrap()); + decl.add_method( + sel!(viewDidChangeBackingProperties), + view_did_change_backing_properties as extern "C" fn(&Object, Sel), + ); + decl.add_method( + sel!(setFrameSize:), + set_frame_size as extern "C" fn(&Object, Sel, NSSize), + ); + decl.add_method( + sel!(displayLayer:), + display_layer as extern "C" fn(&Object, Sel, id), + ); - decl.add_protocol(Protocol::get("NSTextInputClient").unwrap()); - decl.add_method( - sel!(validAttributesForMarkedText), - valid_attributes_for_marked_text as extern "C" fn(&Object, Sel) -> id, - ); - decl.add_method( - sel!(hasMarkedText), - has_marked_text as extern "C" fn(&Object, Sel) -> BOOL, - ); - decl.add_method( - sel!(markedRange), - marked_range as extern "C" fn(&Object, Sel) -> NSRange, - ); - decl.add_method( - sel!(selectedRange), - selected_range as extern "C" fn(&Object, Sel) -> NSRange, - ); - decl.add_method( - sel!(firstRectForCharacterRange:actualRange:), - first_rect_for_character_range - as extern "C" fn(&Object, Sel, NSRange, id) -> NSRect, - ); - decl.add_method( - sel!(insertText:replacementRange:), - insert_text as extern "C" fn(&Object, Sel, id, NSRange), - ); - decl.add_method( - sel!(setMarkedText:selectedRange:replacementRange:), - set_marked_text as extern "C" fn(&Object, Sel, id, NSRange, NSRange), - ); - decl.add_method(sel!(unmarkText), unmark_text as extern "C" fn(&Object, Sel)); - decl.add_method( - sel!(attributedSubstringForProposedRange:actualRange:), - attributed_substring_for_proposed_range - as extern "C" fn(&Object, Sel, NSRange, *mut c_void) -> id, - ); - decl.add_method( - sel!(viewDidChangeEffectiveAppearance), - view_did_change_effective_appearance as extern "C" fn(&Object, Sel), - ); + decl.add_protocol(Protocol::get("NSTextInputClient").unwrap()); + decl.add_method( + sel!(validAttributesForMarkedText), + valid_attributes_for_marked_text as extern "C" fn(&Object, Sel) -> id, + ); + decl.add_method( + sel!(hasMarkedText), + has_marked_text as extern "C" fn(&Object, Sel) -> BOOL, + ); + decl.add_method( + sel!(markedRange), + marked_range as extern "C" fn(&Object, Sel) -> NSRange, + ); + decl.add_method( + sel!(selectedRange), + selected_range as extern "C" fn(&Object, Sel) -> NSRange, + ); + decl.add_method( + sel!(firstRectForCharacterRange:actualRange:), + first_rect_for_character_range + as extern "C" fn(&Object, Sel, NSRange, id) -> NSRect, + ); + decl.add_method( + sel!(insertText:replacementRange:), + insert_text as extern "C" fn(&Object, Sel, id, NSRange), + ); + decl.add_method( + sel!(setMarkedText:selectedRange:replacementRange:), + set_marked_text as extern "C" fn(&Object, Sel, id, NSRange, NSRange), + ); + decl.add_method(sel!(unmarkText), unmark_text as extern "C" fn(&Object, Sel)); + decl.add_method( + sel!(attributedSubstringForProposedRange:actualRange:), + attributed_substring_for_proposed_range + as extern "C" fn(&Object, Sel, NSRange, *mut c_void) -> id, + ); + decl.add_method( + sel!(viewDidChangeEffectiveAppearance), + view_did_change_effective_appearance as extern "C" fn(&Object, Sel), + ); - // Suppress beep on keystrokes with modifier keys. - decl.add_method( - sel!(doCommandBySelector:), - do_command_by_selector as extern "C" fn(&Object, Sel, Sel), - ); + // Suppress beep on keystrokes with modifier keys. + decl.add_method( + sel!(doCommandBySelector:), + do_command_by_selector as extern "C" fn(&Object, Sel, Sel), + ); - decl.add_method( - sel!(acceptsFirstMouse:), - accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, - ); + decl.add_method( + sel!(acceptsFirstMouse:), + accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, + ); - decl.add_method( - sel!(characterIndexForPoint:), - character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, - ); - } + decl.add_method( + sel!(characterIndexForPoint:), + character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, + ); decl.register() }; BLURRED_VIEW_CLASS = { let mut decl = ClassDecl::new("BlurredView", class!(NSVisualEffectView)).unwrap(); - unsafe { - decl.add_method( - sel!(initWithFrame:), - blurred_view_init_with_frame as extern "C" fn(&Object, Sel, NSRect) -> id, - ); - decl.add_method( - sel!(updateLayer), - blurred_view_update_layer as extern "C" fn(&Object, Sel), - ); - decl.register() - } + decl.add_method( + sel!(initWithFrame:), + blurred_view_init_with_frame as extern "C" fn(&Object, Sel, NSRect) -> id, + ); + decl.add_method( + sel!(updateLayer), + blurred_view_update_layer as extern "C" fn(&Object, Sel), + ); + decl.register() }; } } From 15718de773582e751f4e349f20409112200e04c3 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 09:41:24 -0700 Subject: [PATCH 08/22] fix: address upstream sync review feedback --- crates/gpui/src/platform.rs | 6 +- crates/gpui/src/text_system/line_wrapper.rs | 34 ++++++++++- crates/gpui_linux/src/linux/wayland/client.rs | 26 +++++--- crates/gpui_linux/src/linux/wayland/window.rs | 3 +- crates/gpui_linux/src/linux/x11/window.rs | 4 +- crates/gpui_wgpu/benches/layout_line.rs | 13 ++++ crates/gpui_wgpu/src/cosmic_text_system.rs | 61 +++++++++++++++++++ crates/gpui_wgpu/src/wgpu_renderer.rs | 51 ++++++++++++++-- crates/gpui_windows/src/events.rs | 7 ++- crates/gpui_windows/src/window.rs | 7 +-- crates/util/src/command/darwin.rs | 2 +- 11 files changed, 188 insertions(+), 26 deletions(-) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 36bb4a6..8b54850 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -207,10 +207,12 @@ pub trait Platform: 'static { /// Hides the mouse cursor until the user moves the mouse over one of /// this application's windows. - fn hide_cursor_until_mouse_moves(&self); + fn hide_cursor_until_mouse_moves(&self) {} /// Returns whether the mouse cursor is currently visible. - fn is_cursor_visible(&self) -> bool; + fn is_cursor_visible(&self) -> bool { + true + } fn should_auto_hide_scrollbars(&self) -> bool; diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 993aaab..4ac930f 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -479,9 +479,10 @@ impl LineWrapper { while low < high { let mid = (low + high) / 2; let candidate = candidates[mid]; - let result = format!("{truncation_affix}{}", &text[candidate..]); - if self.wrapped_line_count(&result, wrap_width) <= max_lines { + if self.wrapped_line_count_with_prefix(truncation_affix, &text[candidate..], wrap_width) + <= max_lines + { high = mid; } else { low = mid + 1; @@ -505,6 +506,35 @@ impl LineWrapper { .sum() } + fn wrapped_line_count_with_prefix( + &mut self, + prefix: &str, + text: &str, + wrap_width: Pixels, + ) -> usize { + let mut lines = text.split('\n'); + let Some(first_line) = lines.next() else { + return 0; + }; + + let first_line_count = self + .wrap_line( + &[LineFragment::text(prefix), LineFragment::text(first_line)], + wrap_width, + ) + .count() + + 1; + + first_line_count + + lines + .map(|line| { + self.wrap_line(&[LineFragment::text(line)], wrap_width) + .count() + + 1 + }) + .sum::() + } + /// Any character in this list should be treated as a word character, /// meaning it can be part of a word that should not be wrapped. pub(crate) fn is_word_char(c: char) -> bool { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index d236a6b..39f6d6e 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -445,7 +445,6 @@ impl WaylandClientState { "wayland: no focused surface to restore cursor style {:?} after hide; cursor may stay invisible", style ); - self.cursor_hidden_window = None; return; }; let Some(wl_pointer) = self.wl_pointer.clone() else { @@ -453,7 +452,6 @@ impl WaylandClientState { "wayland: no wl_pointer to restore cursor style {:?} after hide; cursor may stay invisible", style ); - self.cursor_hidden_window = None; return; }; let scale = focused_window.primary_output_scale(); @@ -788,14 +786,22 @@ impl LinuxClient for WaylandClient { let parent = state.keyboard_focused_window.clone(); - let target_output = params.display_id.and_then(|display_id| { - let target_protocol_id: u64 = display_id.into(); - state - .wl_outputs - .iter() - .find(|(id, _)| id.protocol_id() as u64 == target_protocol_id) - .map(|(_, output)| output.clone()) - }); + let target_output = match params.display_id { + Some(display_id) => { + let target_protocol_id: u64 = display_id.into(); + Some( + state + .wl_outputs + .iter() + .find(|(id, _)| id.protocol_id() as u64 == target_protocol_id) + .map(|(_, output)| output.clone()) + .ok_or_else(|| { + anyhow::anyhow!("no Wayland output found for display {:?}", display_id) + })?, + ) + } + None => None, + }; let (window, surface_id) = WaylandWindow::new( handle, diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index e1de6c8..7d9f971 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1487,8 +1487,9 @@ impl PlatformWindow for WaylandWindow { callback: callbacks.deactivation, }; - let adapter = + let mut adapter = accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + adapter.update_window_focus_state(self.borrow().active); self.borrow_mut().accesskit_adapter = Some(adapter); } diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 0ea7665..ad793fe 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1264,6 +1264,7 @@ impl X11WindowStatePtr { } pub fn set_active(&self, focus: bool) { + self.state.borrow_mut().active = focus; if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { fun(focus); } @@ -1881,8 +1882,9 @@ impl PlatformWindow for X11Window { callback: callbacks.deactivation, }; - let adapter = + let mut adapter = accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + adapter.update_window_focus_state(self.0.state.borrow().active); self.0.state.borrow_mut().accesskit_adapter = Some(adapter); } diff --git a/crates/gpui_wgpu/benches/layout_line.rs b/crates/gpui_wgpu/benches/layout_line.rs index 94fae68..8f7f18c 100644 --- a/crates/gpui_wgpu/benches/layout_line.rs +++ b/crates/gpui_wgpu/benches/layout_line.rs @@ -45,6 +45,10 @@ fn code_text() -> String { .repeat(8) // 7,928 chars } +fn mixed_fallback_text() -> String { + code_text().replace("compute_run_spans", "compute_run_spans_\u{FB01}") +} + fn bench_layout_line(c: &mut Criterion) { let system = CosmicTextSystem::new_without_system_fonts("Lilex"); system @@ -60,6 +64,7 @@ fn bench_layout_line(c: &mut Criterion) { }; let text = code_text(); + let mixed_text = mixed_fallback_text(); let runs_no_fallback = vec![FontRun { len: text.len(), @@ -69,6 +74,10 @@ fn bench_layout_line(c: &mut Criterion) { len: text.len(), font_id: font_id_with_fallback, }]; + let runs_with_mixed_fallback = vec![FontRun { + len: mixed_text.len(), + font_id: font_id_with_fallback, + }]; let mut group = c.benchmark_group("layout_line"); @@ -80,6 +89,10 @@ fn bench_layout_line(c: &mut Criterion) { b.iter(|| system.layout_line(&text, px(14.0), &runs_with_fallback)) }); + group.bench_function("fallback_chain_mixed_text", |b| { + b.iter(|| system.layout_line(&mixed_text, px(14.0), &runs_with_mixed_fallback)) + }); + group.finish(); } diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index 5670af1..cfeb2cc 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -730,6 +730,29 @@ fn compute_run_spans( } let run_text = &text[run_offset..run_end]; + if run_text.chars().all(|ch| covers(primary, ch)) { + spans.push(RunSpan { + start: run_offset, + end: run_end, + slot: None, + font_id: primary, + }); + return spans; + } + + let fallback_covers_any_missing_primary_char = run_text.chars().any(|ch| { + !covers(primary, ch) && fallback_chain.iter().any(|(fb_id, _)| covers(*fb_id, ch)) + }); + if !fallback_covers_any_missing_primary_char { + spans.push(RunSpan { + start: run_offset, + end: run_end, + slot: None, + font_id: primary, + }); + return spans; + } + let mut span_start = run_offset; let mut span_slot: Option = None; let mut span_font_id = primary; @@ -1066,6 +1089,44 @@ mod tests { assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); } + #[test] + fn run_spans_use_primary_span_when_primary_covers_all_chars() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, _: char| id == primary; + let text = "a字\u{0301}"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_use_primary_span_when_no_fallback_covers_missing_primary_chars() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2]); + let covers = |id: FontId, ch: char| id == primary && ch.is_ascii(); + let text = "a字b"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_keep_ascii_graphemes_on_primary_even_when_fallback_covers() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { ch == 'a' } else { ch == 'b' } + }; + let text = "ab"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + #[test] fn run_spans_collapse_adjacent_same_slot() { let primary = fid(0); diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index bbfde3e..1ad2fdf 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -21,6 +21,20 @@ const INITIAL_INSTANCE_BUFFER_SIZE: u64 = 2 * 1024 * 1024; const INSTANCE_BUFFER_SIZE_BUCKET: u64 = 1024 * 1024; const MAX_INSTANCE_BUFFER_SIZE: u64 = 256 * 1024 * 1024; +fn select_present_mode( + preferred: Option, + supported: &[wgpu::PresentMode], +) -> wgpu::PresentMode { + preferred + .filter(|mode| { + matches!( + mode, + wgpu::PresentMode::AutoVsync | wgpu::PresentMode::AutoNoVsync + ) || supported.contains(mode) + }) + .unwrap_or(wgpu::PresentMode::Fifo) +} + #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct GlobalParams { @@ -311,10 +325,8 @@ impl WgpuRenderer { opaque_alpha_mode }; - let present_mode = config - .preferred_present_mode - .filter(|mode| surface_caps.present_modes.contains(mode)) - .unwrap_or(wgpu::PresentMode::Fifo); + let present_mode = + select_present_mode(config.preferred_present_mode, &surface_caps.present_modes); Self::new_with_surface( context, @@ -2656,6 +2668,37 @@ impl WgpuRenderer { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_present_mode_accepts_auto_modes_without_surface_advertising_them() { + assert_eq!( + select_present_mode( + Some(wgpu::PresentMode::AutoVsync), + &[wgpu::PresentMode::Fifo] + ), + wgpu::PresentMode::AutoVsync + ); + assert_eq!( + select_present_mode( + Some(wgpu::PresentMode::AutoNoVsync), + &[wgpu::PresentMode::Fifo] + ), + wgpu::PresentMode::AutoNoVsync + ); + } + + #[test] + fn select_present_mode_falls_back_to_fifo_for_unsupported_explicit_mode() { + assert_eq!( + select_present_mode(Some(wgpu::PresentMode::Mailbox), &[wgpu::PresentMode::Fifo]), + wgpu::PresentMode::Fifo + ); + } +} + struct RenderingParameters { path_sample_count: u32, gamma_ratios: [f32; 4], diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 044c863..8a22539 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -1087,7 +1087,12 @@ impl WindowsWindowInner { }); if had_cursor != self.state.current_cursor.get().is_some() { - unsafe { SetCursor(self.state.current_cursor.get()) }; + let cursor = if self.state.cursor_visible.load(Ordering::Relaxed) { + self.state.current_cursor.get() + } else { + None + }; + unsafe { SetCursor(cursor) }; } Some(0) diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 62acba3..f1acf6c 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -471,11 +471,10 @@ impl WindowsWindow { let hinstance = get_module_handle(); let display = if let Some(display_id) = params.display_id { WindowsDisplay::new(display_id) + .with_context(|| format!("requested display {display_id:?} is not available"))? } else { - None - } - .or_else(WindowsDisplay::primary_monitor) - .context("failed to find any monitor")?; + WindowsDisplay::primary_monitor().context("failed to find any monitor")? + }; let appearance = system_appearance().unwrap_or_default(); let mut context = WindowCreateContext { inner: None, diff --git a/crates/util/src/command/darwin.rs b/crates/util/src/command/darwin.rs index 5847156..fb727bd 100644 --- a/crates/util/src/command/darwin.rs +++ b/crates/util/src/command/darwin.rs @@ -545,7 +545,7 @@ fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> { } fn open_dev_null(flags: libc::c_int) -> io::Result { - // Set close-on-exec for this pipe, for the same reason as in `create_pipe`. + // Set close-on-exec for this file descriptor, for the same reason as in `create_pipe`. let fd = unsafe { libc::open( c"/dev/null".as_ptr() as *const libc::c_char, From b6e0959e1e0efef296c30f5714d6d9f40f2edcda Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 10:12:45 -0700 Subject: [PATCH 09/22] fix: address safe upstream sync review findings --- crates/gpui/src/elements/div.rs | 19 +++++++--------- crates/gpui/src/elements/list.rs | 8 +++---- crates/gpui/src/elements/retained_layer.rs | 2 ++ crates/gpui/src/elements/text.rs | 4 ++-- crates/gpui/src/platform.rs | 14 ++++++++++++ crates/gpui/src/scene.rs | 25 ++++++++++++++++++++- crates/gpui/src/text_system.rs | 22 +++++++++++++----- crates/gpui/src/text_system/line_wrapper.rs | 17 ++++++++++++++ crates/gpui_linux/src/linux/x11/client.rs | 14 +++++++++--- crates/gpui_linux/src/linux/x11/window.rs | 7 +++++- crates/gpui_macos/src/shaders.metal | 2 +- crates/gpui_wgpu/src/cosmic_text_system.rs | 10 ++++----- crates/gpui_windows/src/window.rs | 5 +++++ crates/scheduler/src/tests.rs | 6 +++++ 14 files changed, 121 insertions(+), 34 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 6f24b0c..e9afa7b 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1944,17 +1944,6 @@ impl Interactivity { if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { window.set_focus_handle(focus_handle, cx); - - if window.a11y.is_active() { - if let Some(node_id) = current_a11y_node_id { - if has_current_a11y_node { - window.a11y.focus_ids.insert(node_id, focus_handle.id); - if focus_handle.is_focused(window) { - window.a11y.nodes.set_focus(node_id); - } - } - } - } } window.with_optional_element_state::( @@ -2007,6 +1996,12 @@ impl Interactivity { if updated_bounds { window.a11y.node_bounds.insert(node_id, translated_bounds); } + if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { + window.a11y.focus_ids.insert(node_id, focus_handle.id); + if focus_handle.is_focused(window) { + window.a11y.nodes.set_focus(node_id); + } + } } } @@ -4085,6 +4080,7 @@ mod tests { fn a11y_display_none_role_div_is_absent_from_tree(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); let focus_handle = cx.update(|_, cx| cx.focus_handle()); + cx.update(|window, cx| focus_handle.focus(window, cx)); let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { let focus_handle = focus_handle.clone(); @@ -4102,6 +4098,7 @@ mod tests { assert!(node_with_role(&update, accesskit::Role::Button).is_none()); assert!(node_with_role(&update, accesskit::Role::Label).is_none()); assert_eq!(root_node(&update).children(), &[]); + assert_eq!(update.focus, crate::window::a11y::ROOT_NODE_ID); assert_tree_has_no_missing_children(&update); cx.update(|window, _| { diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 0de7939..9d1e5e0 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -749,7 +749,7 @@ impl ListState { } let scroll_top = self.logical_scroll_top(); - if ix < scroll_top.item_ix { + if scroll_top.item_ix < self.item_count() && ix < scroll_top.item_ix { // Rows before the logical scroll top have no item bounds, but // their position relative to the viewport is known from scroll state. return Some(true); @@ -768,7 +768,7 @@ impl ListState { } let scroll_top = self.logical_scroll_top(); - if ix < scroll_top.item_ix { + if scroll_top.item_ix < self.item_count() && ix < scroll_top.item_ix { // Rows before the logical scroll top have no item bounds, but // their position relative to the viewport is known from scroll state. return Some(false); @@ -1782,8 +1782,8 @@ mod test { state.scroll_to_end(); assert_eq!(state.logical_scroll_top().item_ix, state.item_count()); - assert_eq!(state.item_is_above_viewport(0), Some(true)); - assert_eq!(state.item_is_below_viewport(0), Some(false)); + assert_eq!(state.item_is_above_viewport(0), None); + assert_eq!(state.item_is_below_viewport(0), None); } #[gpui::test] diff --git a/crates/gpui/src/elements/retained_layer.rs b/crates/gpui/src/elements/retained_layer.rs index 80f22ab..5ba892c 100644 --- a/crates/gpui/src/elements/retained_layer.rs +++ b/crates/gpui/src/elements/retained_layer.rs @@ -178,6 +178,8 @@ impl Element for RetainedLayerElement { state.content_revision != content_revision || state.bounds != bounds || state.content_mask != content_mask + // Repaint while accessibility is active so descendants + // re-emit nodes, bounds, and action listeners each frame. || window.a11y.is_active() } None => true, diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 3bc5009..15caaa6 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -558,7 +558,7 @@ impl TextLayout { } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let (text, runs) = if truncate_width.is_some() { + let (text, runs) = if let Some(truncate_width) = truncate_width { if let Some(max_lines) = text_style.line_clamp && let Some(wrap_width) = wrap_width { @@ -573,7 +573,7 @@ impl TextLayout { } else { line_wrapper.truncate_line( text.clone(), - truncate_width.unwrap(), + truncate_width, &truncation_affix, &runs, truncate_from, diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 8b54850..3f96eb3 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -335,12 +335,26 @@ impl From for DisplayId { } } +impl From for DisplayId { + fn from(id: u32) -> Self { + Self(u64::from(id)) + } +} + impl From for u64 { fn from(id: DisplayId) -> Self { id.0 } } +impl TryFrom for u32 { + type Error = std::num::TryFromIntError; + + fn try_from(id: DisplayId) -> Result { + Self::try_from(id.0) + } +} + impl Debug for DisplayId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "DisplayId({})", self.0) diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 2edb130..ed27b3c 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -339,7 +339,7 @@ pub enum Primitive { impl Primitive { pub fn bounds(&self) -> &Bounds { match self { - Primitive::Shadow(shadow) if shadow.inset == 1 => &shadow.element_bounds, + Primitive::Shadow(shadow) if shadow.inset != 0 => &shadow.element_bounds, Primitive::Shadow(shadow) => &shadow.bounds, Primitive::BackdropBlur(blur) => &blur.bounds, Primitive::Quad(quad) => &quad.bounds, @@ -1189,4 +1189,27 @@ mod tests { assert_eq!(next_scene.paint_operations.len(), 2); assert_eq!(next_scene.retained_layers[0].paint_range, 1..2); } + + #[test] + fn shadow_bounds_treats_any_nonzero_inset_as_inset() { + let bounds = test_bounds(); + let element_bounds = Bounds::new( + point(ScaledPixels(1.0), ScaledPixels(1.0)), + size(ScaledPixels(2.0), ScaledPixels(2.0)), + ); + let shadow = Primitive::Shadow(Shadow { + order: 0, + blur_radius: ScaledPixels(0.0), + bounds, + corner_radii: Corners::all(ScaledPixels(0.0)), + content_mask: ContentMask { bounds }, + color: Hsla::default(), + element_bounds, + element_corner_radii: Corners::all(ScaledPixels(0.0)), + inset: 2, + pad: 0, + }); + + assert_eq!(shadow.bounds(), &element_bounds); + } } diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 08fed21..b6754ad 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -207,8 +207,10 @@ impl TextSystem { Ok(result * font_size) } - // Consider removing this? - /// Returns the shaped layout width of for the given character, in the given font and size. + /// Returns this text system's shaped layout width for the given character. + /// + /// This intentionally goes through the platform text system, while + /// [`WindowTextSystem::layout_width`] uses the window-local line layout cache. pub fn layout_width(&self, font_id: FontId, font_size: Pixels, ch: char) -> Pixels { let mut buffer = [0; 4]; let buffer = ch.encode_utf8(&mut buffer); @@ -238,8 +240,10 @@ impl TextSystem { Ok(self.advance(font_id, font_size, 'm')?.width) } - // Consider removing this? - /// Returns the shaped layout width of an `em`. + /// Returns this text system's shaped layout width of an `em`. + /// + /// This intentionally goes through the platform text system, while + /// [`WindowTextSystem::em_layout_width`] uses the window-local line layout cache. pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { self.layout_width(font_id, font_size, 'm') } @@ -698,7 +702,10 @@ impl WindowTextSystem { layout } - /// Returns the shaped layout width of for the given character, in the given font and size. + /// Returns the window-local cached shaped layout width for the given character. + /// + /// This intentionally uses the line layout cache, while + /// [`TextSystem::layout_width`] goes directly through the platform text system. pub fn layout_width(&self, font_id: FontId, font_size: Pixels, ch: char) -> Pixels { let mut buffer = [0; 4]; let buffer: &_ = ch.encode_utf8(&mut buffer); @@ -715,7 +722,10 @@ impl WindowTextSystem { .width } - /// Returns the shaped layout width of an `em`. + /// Returns the window-local cached shaped layout width of an `em`. + /// + /// This intentionally uses the line layout cache, while + /// [`TextSystem::em_layout_width`] goes directly through the platform text system. pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { self.layout_width(font_id, font_size, 'm') } diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 4ac930f..5d4a1f0 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -216,6 +216,10 @@ impl LineWrapper { runs: &'a [TextRun], truncate_from: TruncateFrom, ) -> (SharedString, Cow<'a, [TextRun]>) { + if max_lines == 0 { + return (SharedString::from(""), Cow::Owned(Vec::new())); + } + if max_lines <= 1 { return self.truncate_single_wrapped_line( text, @@ -1294,6 +1298,19 @@ mod tests { assert!(truncated.ends_with('…')); } + #[test] + fn test_wrapped_truncation_zero_lines_returns_empty_text_and_runs() { + let mut wrapper = build_wrapper(); + let text = "aa bbbbbb cccccc"; + let runs = generate_test_runs(&[2, 1, text.len() - 3]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 0, "…", &runs, TruncateFrom::End); + + assert_eq!(result, ""); + assert!(result_runs.is_empty()); + } + #[test] fn test_multiline_truncation_no_truncation_needed() { let mut wrapper = build_wrapper(); diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 56d8b89..694ee28 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -2070,7 +2070,7 @@ impl X11ClientState { let Some(invisible_cursor) = self.get_or_create_invisible_cursor() else { return; }; - check_reply( + let hide_result = check_reply( || "Failed to hide cursor", self.xcb_connection.change_window_attributes( focused_window, @@ -2080,9 +2080,17 @@ impl X11ClientState { }, ), ) + .and_then(|()| { + self.xcb_connection + .flush() + .map(|_| ()) + .map_err(handle_connection_error) + .context("X11 flush failed") + }) .log_err(); - self.xcb_connection.flush().log_err(); - self.cursor_hidden_window = Some(focused_window); + if hide_result.is_some() { + self.cursor_hidden_window = Some(focused_window); + } } fn restore_cursor_after_hide(&mut self) { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index ad793fe..c884333 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -344,7 +344,12 @@ impl rwh::HasDisplayHandle for X11Window { }; let screen_id = { let state = self.0.state.borrow(); - u64::from(state.display.id()) as i32 + let display_id = u64::from(state.display.id()); + let Ok(screen_id) = i32::try_from(display_id) else { + log::error!("X11 display id {display_id} cannot fit into XCB screen id"); + return Err(rwh::HandleError::Unavailable); + }; + screen_id }; let handle = rwh::XcbDisplayHandle::new(Some(non_zero), screen_id); Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal index 90b5d83..e20f443 100644 --- a/crates/gpui_macos/src/shaders.metal +++ b/crates/gpui_macos/src/shaders.metal @@ -730,7 +730,7 @@ fragment float4 shadow_fragment(ShadowFragmentInput input [[stage_in]], } if (shadow.inset != 0u) { - alpha = 1. - alpha; + alpha = saturate(1. - alpha); float element_distance = quad_sdf(input.position.xy, shadow.element_bounds, shadow.element_corner_radii); alpha *= saturate(0.5 - element_distance); diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index cfeb2cc..270d993 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -802,9 +802,6 @@ fn pick_covering_slot( fallback_chain: &[(FontId, SharedString)], covers: &impl Fn(FontId, char) -> bool, ) -> Option { - if grapheme.is_ascii() { - return None; - } if slot_covers_grapheme(primary, grapheme, covers) { return None; } @@ -1114,7 +1111,7 @@ mod tests { } #[test] - fn run_spans_keep_ascii_graphemes_on_primary_even_when_fallback_covers() { + fn run_spans_use_fallback_for_missing_ascii_graphemes() { let primary = fid(0); let fallback_chain = chain(&[1]); let covers = |id: FontId, ch: char| { @@ -1124,7 +1121,10 @@ mod tests { let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); - assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + assert_eq!( + spans.as_slice(), + &[span(0, 1, None, primary), span(1, 2, Some(0), fid(1))] + ); } #[test] diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index f1acf6c..896d1e3 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -1113,6 +1113,11 @@ impl PlatformWindow for WindowsWindow { } fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + if self.state.a11y.borrow().is_some() { + log::warn!("Windows accessibility adapter already initialized for window"); + return; + } + let action_handler = A11yActionHandler(callbacks.action); let is_focused = unsafe { GetForegroundWindow() } == self.0.hwnd; diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index a6a5408..c957af9 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -171,16 +171,22 @@ fn test_foreground_task_can_hold_mut_borrow_across_await() { TestScheduler::once(async |scheduler| { let foreground = scheduler.foreground(); let (sender, mut receiver) = mpsc::unbounded::<()>(); + let (completion_sender, completion_receiver) = oneshot::channel::<()>(); foreground .spawn(async move { receiver.next().await; + completion_sender.send(()).ok(); }) .detach(); scheduler.run(); sender.unbounded_send(()).unwrap(); scheduler.run(); + assert!( + matches!(completion_receiver.now_or_never(), Some(Ok(()))), + "foreground task should resume after receiving from the channel" + ); }); } From c5a42a6d247eeb362a5973bad7ed0da4d6d7876a Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 10:17:50 -0700 Subject: [PATCH 10/22] fix: report x11 cursor visibility from focused window --- crates/gpui_linux/src/linux/x11/client.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 694ee28..749ca43 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -1686,7 +1686,8 @@ impl LinuxClient for X11Client { } fn is_cursor_visible(&self) -> bool { - self.0.borrow().cursor_hidden_window.is_none() + let state = self.0.borrow(); + state.cursor_hidden_window != state.mouse_focused_window } fn open_uri(&self, uri: &str) { From 892502b76ea7b92068adcb7649751e2557c27252 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 10:49:16 -0700 Subject: [PATCH 11/22] fix: tighten scheduler and darwin review follow-ups --- crates/gpui/src/executor.rs | 11 + crates/gpui/src/platform_scheduler.rs | 4 +- crates/scheduler/src/executor.rs | 20 +- crates/scheduler/src/scheduler.rs | 4 +- crates/scheduler/src/test_scheduler.rs | 4 +- crates/scheduler/src/tests.rs | 13 +- crates/util/src/command/darwin.rs | 418 ++++++++++++++++++------- 7 files changed, 340 insertions(+), 134 deletions(-) diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 2a02e0b..bb5eb23 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -503,4 +503,15 @@ mod test { "Task should run normally when app is alive" ); } + + #[test] + fn task_ext_remains_available_from_gpui_prelude() { + use crate::prelude::*; + + let (dispatcher, _background_executor, app) = create_test_app(); + let task: Task> = Task::ready(Ok(())); + + task.detach_and_log_err(&app.borrow()); + dispatcher.run_until_parked(); + } } diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index aea2408..ec9039f 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -142,11 +142,11 @@ impl Scheduler for PlatformScheduler { dyn FnOnce( LocalExecutor, ) - -> Pin> + 'static>> + -> Pin> + 'static>> + Send + 'static, >, - ) -> Task> { + ) -> Task> { let session_id = self.next_session_id(); spawn_dedicated_thread(session_id, self, move |executor| f(executor)) } diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index b6923f3..36b062f 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -123,7 +123,7 @@ impl LocalExecutor { where F: FnOnce(LocalExecutor) -> Fut + Send + 'static, Fut: Future + 'static, - Fut::Output: Send + Sync + 'static, + Fut::Output: Send + 'static, { self.scheduler .clone() @@ -135,17 +135,17 @@ impl LocalExecutor { fn box_dedicated( f: F, ) -> Box< - dyn FnOnce(LocalExecutor) -> Pin> + 'static>> + dyn FnOnce(LocalExecutor) -> Pin> + 'static>> + Send + 'static, > where F: FnOnce(LocalExecutor) -> Fut + Send + 'static, Fut: Future + 'static, - Fut::Output: Send + Sync + 'static, + Fut::Output: Send + 'static, { Box::new(move |executor| { - Box::pin(async move { Box::new(f(executor).await) as Box }) + Box::pin(async move { Box::new(f(executor).await) as Box }) }) } @@ -237,7 +237,7 @@ impl BackgroundExecutor { where F: FnOnce(LocalExecutor) -> Fut + Send + 'static, Fut: Future + 'static, - Fut::Output: Send + Sync + 'static, + Fut::Output: Send + 'static, { self.scheduler .clone() @@ -262,9 +262,9 @@ enum TaskState { /// A task that is currently running. Spawned(async_task::Task), - /// A typed view of a [`Task>`]. + /// A typed view of a [`Task>`]. Downcast { - inner: Box>>, + inner: Box>>, marker: PhantomData T>, }, } @@ -310,9 +310,9 @@ impl Task { } } -impl Task> { +impl Task> { /// Reinterprets the boxed output as a concrete `T` via downcast on completion. - pub fn downcast(self) -> Task { + pub fn downcast(self) -> Task { Task(TaskState::Downcast { inner: Box::new(self), marker: PhantomData, @@ -345,7 +345,7 @@ enum FallibleTaskState { /// Mirror of [`TaskState::Downcast`] for fallible tasks. Downcast { - inner: Box>>, + inner: Box>>, marker: PhantomData T>, }, } diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 371000c..5c8c10b 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -113,11 +113,11 @@ pub trait Scheduler: Send + Sync { dyn FnOnce( LocalExecutor, ) - -> Pin> + 'static>> + -> Pin> + 'static>> + Send + 'static, >, - ) -> Task>; + ) -> Task>; fn as_test(&self) -> Option<&TestScheduler> { None diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 4efc65c..bf6533d 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -647,11 +647,11 @@ impl Scheduler for TestScheduler { dyn FnOnce( LocalExecutor, ) - -> Pin> + 'static>> + -> Pin> + 'static>> + Send + 'static, >, - ) -> Task> { + ) -> Task> { let session_id = self.allocate_session_id(); let scheduler = Arc::downgrade(&self); let executor = LocalExecutor::new(session_id, self, move |runnable| { diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index c957af9..0a5711f 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -8,7 +8,7 @@ use futures::{ stream::{FuturesUnordered, StreamExt}, }; use std::{ - cell::RefCell, + cell::{Cell, RefCell}, collections::{BTreeSet, HashSet}, pin::Pin, rc::Rc, @@ -738,6 +738,17 @@ fn test_spawn_dedicated_not_send_future() { assert_eq!(result, 5); } +#[test] +fn test_spawn_dedicated_output_only_needs_send() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async { Cell::new(7_u32) }) + .await + }); + assert_eq!(result.get(), 7); +} + #[test] fn test_spawn_dedicated_send_closure_captures() { use parking_lot::Mutex; diff --git a/crates/util/src/command/darwin.rs b/crates/util/src/command/darwin.rs index fb727bd..77a0ce1 100644 --- a/crates/util/src/command/darwin.rs +++ b/crates/util/src/command/darwin.rs @@ -13,6 +13,15 @@ use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Output}; use std::ptr; +use std::sync::{Mutex, MutexGuard}; + +static FD_SPAWN_LOCK: Mutex<()> = Mutex::new(()); + +fn fd_spawn_lock() -> MutexGuard<'static, ()> { + FD_SPAWN_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()) +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Stdio { @@ -376,145 +385,156 @@ fn spawn_posix_spawn( .collect(); envp_ptrs.push(ptr::null_mut()); - let (stdin_read, stdin_write) = match stdin_cfg { - Stdio::Piped => { - let (r, w) = create_pipe()?; - (Some(r), Some(w)) - } - Stdio::Null => { - let fd = open_dev_null(libc::O_RDONLY)?; - (Some(fd), None) - } - Stdio::Inherit => (None, None), - }; + let (pid, stdin, stdout, stderr) = { + let _lock = fd_spawn_lock(); - let (stdout_read, stdout_write) = match stdout_cfg { - Stdio::Piped => { - let (r, w) = create_pipe()?; - (Some(r), Some(w)) - } - Stdio::Null => { - let fd = open_dev_null(libc::O_WRONLY)?; - (None, Some(fd)) - } - Stdio::Inherit => (None, None), - }; + let (stdin_read, stdin_write) = match stdin_cfg { + Stdio::Piped => { + let (r, w) = create_pipe_locked()?; + (Some(FdGuard::new(r)), Some(FdGuard::new(w))) + } + Stdio::Null => { + let fd = open_dev_null(libc::O_RDONLY)?; + (Some(FdGuard::new(fd)), None) + } + Stdio::Inherit => (None, None), + }; - let (stderr_read, stderr_write) = match stderr_cfg { - Stdio::Piped => { - let (r, w) = create_pipe()?; - (Some(r), Some(w)) - } - Stdio::Null => { - let fd = open_dev_null(libc::O_WRONLY)?; - (None, Some(fd)) - } - Stdio::Inherit => (None, None), - }; + let (stdout_read, stdout_write) = match stdout_cfg { + Stdio::Piped => { + let (r, w) = create_pipe_locked()?; + (Some(FdGuard::new(r)), Some(FdGuard::new(w))) + } + Stdio::Null => { + let fd = open_dev_null(libc::O_WRONLY)?; + (None, Some(FdGuard::new(fd))) + } + Stdio::Inherit => (None, None), + }; - let mut attr: libc::posix_spawnattr_t = ptr::null_mut(); - let mut file_actions: libc::posix_spawn_file_actions_t = ptr::null_mut(); + let (stderr_read, stderr_write) = match stderr_cfg { + Stdio::Piped => { + let (r, w) = create_pipe_locked()?; + (Some(FdGuard::new(r)), Some(FdGuard::new(w))) + } + Stdio::Null => { + let fd = open_dev_null(libc::O_WRONLY)?; + (None, Some(FdGuard::new(fd))) + } + Stdio::Inherit => (None, None), + }; - unsafe { - cvt_nz(libc::posix_spawnattr_init(&mut attr))?; - cvt_nz(libc::posix_spawn_file_actions_init(&mut file_actions))?; - - cvt_nz(libc::posix_spawnattr_setflags( - &mut attr, - libc::POSIX_SPAWN_CLOEXEC_DEFAULT as libc::c_short, - ))?; - - cvt_nz(posix_spawnattr_setexceptionports_np( - &mut attr, - EXC_MASK_ALL, - MACH_PORT_NULL, - EXCEPTION_DEFAULT as exception_behavior_t, - THREAD_STATE_NONE, - ))?; - - cvt_nz(posix_spawn_file_actions_addchdir_np( - &mut file_actions, - current_dir_cstr.as_ptr(), - ))?; - - if let Some(fd) = stdin_read { - cvt_nz(libc::posix_spawn_file_actions_adddup2( - &mut file_actions, - fd, - libc::STDIN_FILENO, - ))?; - cvt_nz(posix_spawn_file_actions_addinherit_np( - &mut file_actions, - libc::STDIN_FILENO, - ))?; - } + let mut attr = SpawnAttr::init()?; + let mut file_actions = SpawnFileActions::init()?; - if let Some(fd) = stdout_write { - cvt_nz(libc::posix_spawn_file_actions_adddup2( - &mut file_actions, - fd, - libc::STDOUT_FILENO, - ))?; - cvt_nz(posix_spawn_file_actions_addinherit_np( - &mut file_actions, - libc::STDOUT_FILENO, + unsafe { + cvt_nz(libc::posix_spawnattr_setflags( + attr.as_mut_ptr(), + libc::POSIX_SPAWN_CLOEXEC_DEFAULT as libc::c_short, ))?; - } - if let Some(fd) = stderr_write { - cvt_nz(libc::posix_spawn_file_actions_adddup2( - &mut file_actions, - fd, - libc::STDERR_FILENO, + cvt_nz(posix_spawnattr_setexceptionports_np( + attr.as_mut_ptr(), + EXC_MASK_ALL, + MACH_PORT_NULL, + EXCEPTION_DEFAULT as exception_behavior_t, + THREAD_STATE_NONE, ))?; - cvt_nz(posix_spawn_file_actions_addinherit_np( - &mut file_actions, - libc::STDERR_FILENO, + + cvt_nz(posix_spawn_file_actions_addchdir_np( + file_actions.as_mut_ptr(), + current_dir_cstr.as_ptr(), ))?; + + if let Some(fd) = &stdin_read { + cvt_nz(libc::posix_spawn_file_actions_adddup2( + file_actions.as_mut_ptr(), + fd.raw(), + libc::STDIN_FILENO, + ))?; + cvt_nz(posix_spawn_file_actions_addinherit_np( + file_actions.as_mut_ptr(), + libc::STDIN_FILENO, + ))?; + } + + if let Some(fd) = &stdout_write { + cvt_nz(libc::posix_spawn_file_actions_adddup2( + file_actions.as_mut_ptr(), + fd.raw(), + libc::STDOUT_FILENO, + ))?; + cvt_nz(posix_spawn_file_actions_addinherit_np( + file_actions.as_mut_ptr(), + libc::STDOUT_FILENO, + ))?; + } + + if let Some(fd) = &stderr_write { + cvt_nz(libc::posix_spawn_file_actions_adddup2( + file_actions.as_mut_ptr(), + fd.raw(), + libc::STDERR_FILENO, + ))?; + cvt_nz(posix_spawn_file_actions_addinherit_np( + file_actions.as_mut_ptr(), + libc::STDERR_FILENO, + ))?; + } } let mut pid: libc::pid_t = 0; - let spawn_result = libc::posix_spawnp( - &mut pid, - program_cstr.as_ptr(), - &file_actions, - &attr, - argv_ptrs.as_ptr(), - if envs.is_some() { - envp_ptrs.as_ptr() - } else { - environ - }, - ); + let spawn_result = unsafe { + libc::posix_spawnp( + &mut pid, + program_cstr.as_ptr(), + file_actions.as_ptr(), + attr.as_ptr(), + argv_ptrs.as_ptr(), + if envs.is_some() { + envp_ptrs.as_ptr() + } else { + environ + }, + ) + }; - libc::posix_spawnattr_destroy(&mut attr); - libc::posix_spawn_file_actions_destroy(&mut file_actions); + drop(file_actions); + drop(attr); + cvt_nz(spawn_result)?; - if let Some(fd) = stdin_read { - libc::close(fd); - } - if let Some(fd) = stdout_write { - libc::close(fd); - } - if let Some(fd) = stderr_write { - libc::close(fd); - } + drop(stdin_read); + drop(stdout_write); + drop(stderr_write); - cvt_nz(spawn_result)?; + ( + pid, + stdin_write.map(FdGuard::into_raw), + stdout_read.map(FdGuard::into_raw), + stderr_read.map(FdGuard::into_raw), + ) + }; + unsafe { Ok(Child { pid, - stdin: stdin_write.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), - stdout: stdout_read.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), - stderr: stderr_read.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), + stdin: stdin.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), + stdout: stdout.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), + stderr: stderr.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), kill_on_drop, status: None, }) } } +#[cfg_attr(not(test), allow(dead_code))] fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> { + let _lock = fd_spawn_lock(); + create_pipe_locked() +} + +fn create_pipe_locked() -> io::Result<(libc::c_int, libc::c_int)> { let mut fds: [libc::c_int; 2] = [0; 2]; unsafe { let result = libc::pipe(fds.as_mut_ptr()); @@ -544,6 +564,96 @@ fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> { } } +struct FdGuard { + fd: libc::c_int, +} + +impl FdGuard { + fn new(fd: libc::c_int) -> Self { + Self { fd } + } + + fn raw(&self) -> libc::c_int { + self.fd + } + + fn into_raw(mut self) -> libc::c_int { + let fd = self.fd; + self.fd = -1; + fd + } +} + +impl Drop for FdGuard { + fn drop(&mut self) { + if self.fd != -1 { + unsafe { + libc::close(self.fd); + } + } + } +} + +struct SpawnAttr { + raw: libc::posix_spawnattr_t, +} + +impl SpawnAttr { + fn init() -> io::Result { + let mut raw = ptr::null_mut(); + unsafe { + cvt_nz(libc::posix_spawnattr_init(&mut raw))?; + } + Ok(Self { raw }) + } + + fn as_mut_ptr(&mut self) -> *mut libc::posix_spawnattr_t { + &mut self.raw + } + + fn as_ptr(&self) -> *const libc::posix_spawnattr_t { + &self.raw + } +} + +impl Drop for SpawnAttr { + fn drop(&mut self) { + unsafe { + libc::posix_spawnattr_destroy(&mut self.raw); + } + } +} + +struct SpawnFileActions { + raw: libc::posix_spawn_file_actions_t, +} + +impl SpawnFileActions { + fn init() -> io::Result { + let mut raw = ptr::null_mut(); + unsafe { + cvt_nz(libc::posix_spawn_file_actions_init(&mut raw))?; + } + Ok(Self { raw }) + } + + fn as_mut_ptr(&mut self) -> *mut libc::posix_spawn_file_actions_t { + &mut self.raw + } + + fn as_ptr(&self) -> *const libc::posix_spawn_file_actions_t { + &self.raw + } +} + +impl Drop for SpawnFileActions { + fn drop(&mut self) { + unsafe { + libc::posix_spawn_file_actions_destroy(&mut self.raw); + } + } +} + fn open_dev_null(flags: libc::c_int) -> io::Result { // Set close-on-exec for this file descriptor, for the same reason as in `create_pipe`. let fd = unsafe { @@ -579,14 +689,16 @@ fn invalid_input_error() -> io::Error { mod tests { use super::*; use futures_lite::AsyncWriteExt; + use std::sync::mpsc; + use std::thread; + use std::time::Duration; // Verifies that pipes returned by `create_pipe` aren't visible to unrelated // child processes spawned via `std::process::Command`. On macOS, `std` // uses `posix_spawn` without `POSIX_SPAWN_CLOEXEC_DEFAULT`, so any - // non-CLOEXEC fd in the parent leaks into the child. Without - // `FD_CLOEXEC` on our pipe fds, an unrelated spawn (a terminal, the crash - // handler, etc.) running concurrently with a piped git child would hold - // git's stdin write end open and deadlock the git child on `read()`. + // non-CLOEXEC fd in the parent leaks into the child. This verifies the + // post-`create_pipe` invariant; the module lock covers the earlier + // pipe-to-FIOCLEX window for participating spawns. #[test] fn test_create_pipe_not_inherited_by_unrelated_spawn() { let (read_fd, write_fd) = create_pipe().expect("create_pipe failed"); @@ -623,6 +735,78 @@ mod tests { ); } + #[test] + fn test_participating_spawn_waits_for_fd_lock() { + let guard = fd_spawn_lock(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + + let worker = thread::spawn(move || { + started_tx.send(()).expect("failed to signal worker start"); + let output = + smol::block_on(async { Command::new("/bin/echo").arg("ready").output().await }); + done_tx.send(output).expect("failed to send command output"); + }); + + started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("worker did not start"); + assert!( + matches!( + done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ), + "participating spawn completed while fd lock was held" + ); + + drop(guard); + + let output = done_rx + .recv_timeout(Duration::from_secs(5)) + .expect("worker did not complete after fd lock was released") + .expect("failed to run command"); + worker.join().expect("worker thread panicked"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"ready\n"); + } + + #[test] + fn test_concurrent_piped_commands_complete_without_fd_inheritance_deadlock() { + let mut threads = Vec::new(); + + for thread_index in 0..4 { + threads.push(thread::spawn(move || { + for iteration in 0..20 { + smol::block_on(async move { + let input = format!("thread {thread_index} iteration {iteration}\n"); + let mut child = Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to spawn cat"); + + let mut stdin = child.stdin.take().expect("stdin should be piped"); + stdin + .write_all(input.as_bytes()) + .await + .expect("failed to write to stdin"); + stdin.close().await.expect("failed to close stdin"); + drop(stdin); + + let output = child.output().await.expect("failed to read output"); + assert!(output.status.success()); + assert_eq!(output.stdout, input.as_bytes()); + }); + } + })); + } + + for thread in threads { + thread.join().expect("worker thread panicked"); + } + } + #[test] fn test_spawn_echo() { smol::block_on(async { From 40a5ba0c0be8530823d7ca2d2ca4f80d9b01ec90 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 11:06:40 -0700 Subject: [PATCH 12/22] fix: clean up async window and a11y lifecycle --- crates/gpui/src/app/async_context.rs | 129 +++++++++++++++-- crates/gpui/src/window/a11y.rs | 204 ++++++++++++++++++++++++++- 2 files changed, 318 insertions(+), 15 deletions(-) diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index cf3337f..6efd34c 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -375,11 +375,9 @@ impl AppContext for AsyncWindowContext { ) }) { Ok(entity) => entity, - Err(_) => self.app.new( - build_entity - .take() - .expect("update_window returned Err without invoking the closure"), - ), + Err(error) => { + panic!("AsyncWindowContext::new failed: bound window could not be updated: {error}") + } } } @@ -398,12 +396,9 @@ impl AppContext for AsyncWindowContext { cx.insert_entity(reservation, build_entity) }) { Ok(entity) => entity, - Err(_) => { - let (reservation, build_entity) = args - .take() - .expect("update_window returned Err without invoking the closure"); - self.app.insert_entity(reservation, build_entity) - } + Err(error) => panic!( + "AsyncWindowContext::insert_entity failed: bound window could not be updated: {error}" + ), } } @@ -518,3 +513,115 @@ impl VisualContext for AsyncWindowContext { }) } } + +#[cfg(test)] +mod tests { + use std::{ + any::Any, + cell::Cell, + panic::{AssertUnwindSafe, catch_unwind}, + rc::Rc, + }; + + use crate::{AppContext, Empty, TestAppContext, WindowHandle}; + + use super::AsyncWindowContext; + + fn open_async_window_context( + cx: &mut TestAppContext, + ) -> (AsyncWindowContext, WindowHandle) { + let window = cx.update(|cx| { + cx.open_window(Default::default(), |_, cx| cx.new(|_| Empty)) + .unwrap() + }); + let async_cx = AsyncWindowContext::new_context(cx.to_async(), window.into()); + (async_cx, window) + } + + fn close_window(cx: &mut TestAppContext, window: WindowHandle) { + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + } + + fn panic_message(error: &(dyn Any + Send)) -> Option<&str> { + error + .downcast_ref::() + .map(String::as_str) + .or_else(|| error.downcast_ref::<&str>().copied()) + } + + #[gpui::test] + fn async_window_context_new_panics_when_bound_window_is_closed(cx: &mut TestAppContext) { + let (mut async_cx, window) = open_async_window_context(cx); + close_window(cx, window); + + let builder_invoked = Rc::new(Cell::new(false)); + let result = { + let builder_invoked = builder_invoked.clone(); + catch_unwind(AssertUnwindSafe(move || { + async_cx.new(|_| { + builder_invoked.set(true); + 1usize + }); + })) + }; + + let error = result.expect_err("AsyncWindowContext::new should panic"); + let message = panic_message(error.as_ref()).expect("panic should include a message"); + assert!( + message.contains("AsyncWindowContext::new failed: bound window could not be updated"), + "{message}" + ); + assert!(message.contains("window not found"), "{message}"); + assert!(!builder_invoked.get()); + } + + #[gpui::test] + fn async_window_context_insert_entity_panics_when_bound_window_is_closed( + cx: &mut TestAppContext, + ) { + let (mut async_cx, window) = open_async_window_context(cx); + let reservation = async_cx.reserve_entity::(); + close_window(cx, window); + + let builder_invoked = Rc::new(Cell::new(false)); + let result = { + let builder_invoked = builder_invoked.clone(); + catch_unwind(AssertUnwindSafe(move || { + async_cx.insert_entity(reservation, |_| { + builder_invoked.set(true); + 1usize + }); + })) + }; + + let error = result.expect_err("AsyncWindowContext::insert_entity should panic"); + let message = panic_message(error.as_ref()).expect("panic should include a message"); + assert!( + message.contains( + "AsyncWindowContext::insert_entity failed: bound window could not be updated" + ), + "{message}" + ); + assert!(message.contains("window not found"), "{message}"); + assert!(!builder_invoked.get()); + } + + #[gpui::test] + fn async_window_context_new_uses_live_window(cx: &mut TestAppContext) { + let (mut async_cx, _) = open_async_window_context(cx); + let builder_invoked = Rc::new(Cell::new(false)); + + let entity = { + let builder_invoked = builder_invoked.clone(); + async_cx.new(|_| { + builder_invoked.set(true); + 42usize + }) + }; + + assert!(builder_invoked.get()); + assert_eq!(async_cx.read_entity(&entity, |value, _| *value), 42); + } +} diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index ab124c2..3c0b120 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -24,6 +24,7 @@ pub(crate) struct A11y { active_flag: Arc, active_this_frame: bool, node_ids: FxHashMap, + visited_global_ids: FxHashSet, next_node_id: u64, pub(crate) nodes: A11yNodeBuilder, pub(crate) focus_ids: FxHashMap, @@ -38,6 +39,7 @@ impl A11y { active_flag, active_this_frame: false, node_ids: FxHashMap::default(), + visited_global_ids: FxHashSet::default(), next_node_id: ROOT_NODE_ID.0 + 1, nodes: A11yNodeBuilder::new(), focus_ids: FxHashMap::default(), @@ -65,10 +67,13 @@ impl A11y { self.focus_ids.clear(); self.node_bounds.clear(); self.action_listeners.clear(); + self.visited_global_ids.clear(); self.nodes.begin_frame(); } pub(crate) fn node_id_for(&mut self, global_id: &GlobalElementId) -> NodeId { + self.visited_global_ids.insert(global_id.clone()); + if let Some(node_id) = self.node_ids.get(global_id) { return *node_id; } @@ -85,13 +90,30 @@ impl A11y { } pub(crate) fn end_frame(&mut self) -> TreeUpdate { - self.nodes.finalize() + let update = self.nodes.finalize(); + let live_node_ids = update + .nodes + .iter() + .map(|(node_id, _)| *node_id) + .collect::>(); + + self.node_ids + .retain(|global_id, _| self.visited_global_ids.contains(global_id)); + self.focus_ids + .retain(|node_id, _| live_node_ids.contains(node_id)); + self.node_bounds + .retain(|node_id, _| live_node_ids.contains(node_id)); + self.action_listeners + .retain(|node_id, _| live_node_ids.contains(node_id)); + + update } pub(crate) fn prepaint_snapshot(&self) -> A11yPrepaintSnapshot { A11yPrepaintSnapshot { nodes: self.nodes.prepaint_snapshot(), node_ids: self.node_ids.clone(), + visited_global_ids: self.visited_global_ids.clone(), next_node_id: self.next_node_id, focus_ids: self.focus_ids.clone(), node_bounds: self.node_bounds.clone(), @@ -101,6 +123,7 @@ impl A11y { pub(crate) fn restore_prepaint_snapshot(&mut self, snapshot: A11yPrepaintSnapshot) { self.nodes.restore_prepaint_snapshot(snapshot.nodes); self.node_ids = snapshot.node_ids; + self.visited_global_ids = snapshot.visited_global_ids; self.next_node_id = snapshot.next_node_id; self.focus_ids = snapshot.focus_ids; self.node_bounds = snapshot.node_bounds; @@ -124,6 +147,7 @@ pub(crate) struct A11yNodeBuilder { pub(crate) struct A11yPrepaintSnapshot { nodes: A11yNodeBuilderPrepaintSnapshot, node_ids: FxHashMap, + visited_global_ids: FxHashSet, next_node_id: u64, focus_ids: FxHashMap, node_bounds: FxHashMap>, @@ -293,9 +317,7 @@ impl A11yNodeBuilder { } *suppressed = true; - if let Some(id) = self.ids_stack.last() { - self.seen_ids.remove(id); - } + self.prune_emitted_subtree(id); true } @@ -363,6 +385,62 @@ impl A11yNodeBuilder { || self.suppression_stack.last().copied().unwrap_or_default() } + fn prune_emitted_subtree(&mut self, id: NodeId) { + let mut pruned_ids = FxHashSet::default(); + pruned_ids.insert(id); + + if let Some(current_node) = self.nodes_stack.last() { + let mut pending = current_node.children().to_vec(); + while let Some(child_id) = pending.pop() { + if !pruned_ids.insert(child_id) { + continue; + } + + if let Some((_, child_node)) = self + .all_nodes + .iter() + .find(|(node_id, _)| *node_id == child_id) + { + pending.extend(child_node.children().iter().copied()); + } + } + } + + for node_id in &pruned_ids { + self.seen_ids.remove(node_id); + } + + if pruned_ids.contains(&self.focus) { + self.focus = ROOT_NODE_ID; + } + + self.all_nodes + .retain(|(node_id, _)| !pruned_ids.contains(node_id)); + + for (_, node) in &mut self.all_nodes { + Self::remove_child_refs(node, &pruned_ids); + } + for node in &mut self.nodes_stack { + Self::remove_child_refs(node, &pruned_ids); + } + } + + fn remove_child_refs(node: &mut accesskit::Node, removed_ids: &FxHashSet) { + if node + .children() + .iter() + .any(|child_id| removed_ids.contains(child_id)) + { + let children = node + .children() + .iter() + .copied() + .filter(|child_id| !removed_ids.contains(child_id)) + .collect::>(); + node.set_children(children); + } + } + fn pop_any(&mut self) { if let (Some(id), Some(node), Some(suppressed)) = ( self.ids_stack.pop(), @@ -444,6 +522,10 @@ mod tests { .unwrap() } + fn has_update_node(update: &TreeUpdate, id: NodeId) -> bool { + update.nodes.iter().any(|(node_id, _)| *node_id == id) + } + #[test] fn preserves_unsuppressed_tree_shape() { let mut builder = A11yNodeBuilder::new(); @@ -587,6 +669,33 @@ mod tests { assert_eq!(update.nodes.len(), 1); } + #[test] + fn suppressing_current_node_prunes_already_emitted_descendants() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::TextInput))); + builder.set_focus(NodeId(3)); + builder.pop(); + builder.pop(); + + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.has_node(NodeId(1))); + assert!(!builder.has_node(NodeId(2))); + assert!(!builder.has_node(NodeId(3))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 1); + assert_eq!(update.focus, ROOT_NODE_ID); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); + assert!(!has_update_node(&update, NodeId(1))); + assert!(!has_update_node(&update, NodeId(2))); + assert!(!has_update_node(&update, NodeId(3))); + } + #[test] fn parent_children_do_not_reference_suppressed_descendants() { let mut builder = A11yNodeBuilder::new(); @@ -684,6 +793,65 @@ mod tests { assert_eq!(a11y.node_id_for_existing(&global_id("missing")), None); } + #[test] + fn retains_visited_suppressed_node_id_and_sweeps_omitted_node_id() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let hidden_id = global_id("hidden"); + + a11y.begin_frame(); + let hidden_node_id = a11y.node_id_for(&hidden_id); + assert!(a11y.nodes.push( + hidden_node_id, + accesskit::Node::new(accesskit::Role::Button) + )); + assert!(a11y.nodes.suppress_current_node(hidden_node_id)); + a11y.nodes.pop(); + let update = a11y.end_frame(); + + assert!(!has_update_node(&update, hidden_node_id)); + assert_eq!(a11y.node_id_for_existing(&hidden_id), Some(hidden_node_id)); + + a11y.begin_frame(); + a11y.end_frame(); + + assert_eq!(a11y.node_id_for_existing(&hidden_id), None); + } + + #[test] + fn sweeps_per_node_maps_by_live_emitted_nodes_after_end_frame() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let live_bounds = Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))); + let stale_bounds = Bounds::new(point(px(5.), px(6.)), size(px(7.), px(8.))); + let live_focus = FocusId::from(KeyData::from_ffi(1)); + let stale_focus = FocusId::from(KeyData::from_ffi(2)); + + a11y.begin_frame(); + assert!( + a11y.nodes + .push(NodeId(1), accesskit::Node::new(accesskit::Role::Button)) + ); + a11y.nodes.pop(); + a11y.focus_ids.insert(NodeId(1), live_focus); + a11y.focus_ids.insert(NodeId(2), stale_focus); + a11y.node_bounds.insert(NodeId(1), live_bounds); + a11y.node_bounds.insert(NodeId(2), stale_bounds); + a11y.action_listeners + .insert(NodeId(1), vec![(Action::Click, Box::new(|_, _, _| {}))]); + a11y.action_listeners + .insert(NodeId(2), vec![(Action::Focus, Box::new(|_, _, _| {}))]); + + let update = a11y.end_frame(); + + assert!(has_update_node(&update, NodeId(1))); + assert!(!has_update_node(&update, NodeId(2))); + assert_eq!(a11y.focus_ids.len(), 1); + assert_eq!(a11y.focus_ids.get(&NodeId(1)), Some(&live_focus)); + assert_eq!(a11y.node_bounds.len(), 1); + assert_eq!(a11y.node_bounds.get(&NodeId(1)), Some(&live_bounds)); + assert_eq!(a11y.action_listeners.len(), 1); + assert!(a11y.action_listeners.contains_key(&NodeId(1))); + } + #[test] fn restores_prepaint_snapshot_for_node_id_allocator() { let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); @@ -705,4 +873,32 @@ mod tests { assert_eq!(a11y.node_id_for_existing(&rejected_id), None); assert_eq!(next_node_id, rejected_node_id); } + + #[test] + fn restores_prepaint_snapshot_for_visited_globals() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let accepted_id = global_id("accepted"); + let rejected_id = global_id("rejected"); + + a11y.begin_frame(); + let accepted_node_id = a11y.node_id_for(&accepted_id); + let snapshot = a11y.prepaint_snapshot(); + let rejected_node_id = a11y.node_id_for(&rejected_id); + assert!(a11y.nodes.push( + accepted_node_id, + accesskit::Node::new(accesskit::Role::Button) + )); + a11y.nodes.pop(); + + a11y.restore_prepaint_snapshot(snapshot); + let update = a11y.end_frame(); + + assert!(!has_update_node(&update, accepted_node_id)); + assert!(!has_update_node(&update, rejected_node_id)); + assert_eq!( + a11y.node_id_for_existing(&accepted_id), + Some(accepted_node_id) + ); + assert_eq!(a11y.node_id_for_existing(&rejected_id), None); + } } From 8d5b3405e57d2b4a3d2560159cad017f1d130c92 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 11:23:20 -0700 Subject: [PATCH 13/22] fix: preserve a11y listener mutations --- crates/gpui/src/elements/div.rs | 54 +++++++++++++++++++++++++++++++++ crates/gpui/src/window.rs | 5 +++ 2 files changed, 59 insertions(+) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index e9afa7b..e3ef38b 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -3997,6 +3997,60 @@ mod tests { assert_eq!(action_count.get(), 1); } + #[gpui::test] + fn a11y_listener_registered_during_dispatch_survives_for_same_target(cx: &mut TestAppContext) { + let original_count = Rc::new(Cell::new(0)); + let registered_count = Rc::new(Cell::new(0)); + let node_id = accesskit::NodeId(1); + let cx = cx.add_empty_window(); + + cx.update(|window, _| { + let original_count = original_count.clone(); + let registered_count = registered_count.clone(); + window.on_a11y_action(node_id, accesskit::Action::Click, move |_, window, _| { + original_count.set(original_count.get() + 1); + let registered_count = registered_count.clone(); + window.on_a11y_action(node_id, accesskit::Action::Click, move |_, _, _| { + registered_count.set(registered_count.get() + 1); + }); + }); + }); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: node_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(original_count.get(), 1); + assert_eq!(registered_count.get(), 0); + assert_eq!( + cx.update(|window, _| window.a11y.action_listeners[&node_id].len()), + 2 + ); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: node_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(original_count.get(), 2); + assert_eq!(registered_count.get(), 1); + } + #[test] fn key_context_accepts_try_into_errors_without_display() { struct KeyContextSource; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 45fa79c..32c8fdb 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5400,6 +5400,11 @@ impl Window { matched = true; } } + if let Some(mut current_listeners) = + self.a11y.action_listeners.remove(&request.target_node) + { + listeners.append(&mut current_listeners); + } self.a11y .action_listeners .insert(request.target_node, listeners); From 436a66aaff1b55188eed5aaeca048f7768b196ad Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 11:50:33 -0700 Subject: [PATCH 14/22] fix: address post-push review findings --- crates/gpui/src/app/async_context.rs | 6 ++ crates/gpui/src/text_system/line_wrapper.rs | 8 +- crates/gpui_linux/src/linux/wayland/client.rs | 4 +- crates/gpui_linux/src/linux/wayland/serial.rs | 77 +++++++++++++++---- crates/gpui_linux/src/linux/x11/client.rs | 31 +++++++- crates/gpui_wgpu/src/cosmic_text_system.rs | 11 ++- crates/gpui_wgpu/src/shaders.wgsl | 2 +- crates/util/src/command/darwin.rs | 5 +- 8 files changed, 119 insertions(+), 25 deletions(-) diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index 6efd34c..051a2af 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -362,6 +362,9 @@ impl AsyncWindowContext { } impl AppContext for AsyncWindowContext { + /// Create a new entity using the bound window's context. + /// + /// Panics if the bound window has been closed before the entity can be created. fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity where T: 'static, @@ -385,6 +388,9 @@ impl AppContext for AsyncWindowContext { self.app.reserve_entity() } + /// Insert a reserved entity using the bound window's context. + /// + /// Panics if the bound window has been closed before the entity can be inserted. fn insert_entity( &mut self, reservation: Reservation, diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 5d4a1f0..23af24e 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -223,7 +223,7 @@ impl LineWrapper { if max_lines <= 1 { return self.truncate_single_wrapped_line( text, - wrap_width * max_lines, + wrap_width, truncation_affix, runs, truncate_from, @@ -257,7 +257,11 @@ impl LineWrapper { for (ix, c) in text.char_indices() { if c == '\n' { - if line >= max_lines - 1 && !text[ix + 1..].trim().is_empty() { + if line >= max_lines - 1 + && text + .get(ix + 1..) + .is_some_and(|suffix| !suffix.trim().is_empty()) + { let result = SharedString::from(format!( "{}{truncation_affix}", trim_end_before_truncation_affix(&text[..truncate_ix], truncation_affix) diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 39f6d6e..ae23a57 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -933,7 +933,7 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set_primary(item); - let serial = state.serial_tracker.get_latest(); + let serial = state.serial_tracker.get_latest_input(); let data_source = primary_selection_manager.create_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); @@ -953,7 +953,7 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set(item); - let serial = state.serial_tracker.get_latest(); + let serial = state.serial_tracker.get_latest_input(); let data_source = data_device_manager.create_data_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); diff --git a/crates/gpui_linux/src/linux/wayland/serial.rs b/crates/gpui_linux/src/linux/wayland/serial.rs index 75fb69e..eaf479a 100644 --- a/crates/gpui_linux/src/linux/wayland/serial.rs +++ b/crates/gpui_linux/src/linux/wayland/serial.rs @@ -9,6 +9,12 @@ pub(crate) enum SerialKind { KeyPress, } +impl SerialKind { + fn is_input(&self) -> bool { + matches!(self, SerialKind::MousePress | SerialKind::KeyPress) + } +} + #[derive(Debug)] struct SerialData { serial: u32, @@ -47,21 +53,62 @@ impl SerialTracker { .unwrap_or(0) } - /// Returns the most recent serial across all tracked kinds. + /// Returns the most recent input serial. /// - /// Wayland compositor serial numbers are monotonically increasing, so the - /// highest value is always the most recently received one. This is the - /// correct serial to use for `set_selection` when the triggering event - /// may have been a mouse press rather than a key press: using 0 (the - /// default when a kind has never been seen) causes compositors to silently - /// reject the request. - /// - /// Returns 0 only if no serial of any kind has been received yet. - pub fn get_latest(&self) -> u32 { - self.serials - .values() - .map(|serial_data| serial_data.serial) - .max() - .unwrap_or(0) + /// Returns 0 only if no input serial has been received yet. + pub fn get_latest_input(&self) -> u32 { + latest_serial( + self.serials + .iter() + .filter_map(|(kind, serial_data)| kind.is_input().then_some(serial_data.serial)), + ) + } +} + +fn latest_serial(serials: impl Iterator) -> u32 { + serials + .reduce(|latest, serial| { + if serial_is_after(serial, latest) { + serial + } else { + latest + } + }) + .unwrap_or(0) +} + +fn serial_is_after(serial: u32, other: u32) -> bool { + const SERIAL_WRAP_THRESHOLD: u32 = 1 << 31; + + serial != other && serial.wrapping_sub(other) < SERIAL_WRAP_THRESHOLD +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn latest_serial_uses_wrapping_order() { + assert_eq!(latest_serial([u32::MAX - 1, 3].into_iter()), 3); + } + + #[test] + fn latest_input_ignores_non_input_serials() { + let mut tracker = SerialTracker::new(); + tracker.update(SerialKind::KeyPress, 10); + tracker.update(SerialKind::MouseEnter, 40); + tracker.update(SerialKind::InputMethod, 50); + tracker.update(SerialKind::DataDevice, 60); + + assert_eq!(tracker.get_latest_input(), 10); + } + + #[test] + fn latest_input_uses_wrapping_order() { + let mut tracker = SerialTracker::new(); + tracker.update(SerialKind::KeyPress, u32::MAX - 1); + tracker.update(SerialKind::MousePress, 3); + + assert_eq!(tracker.get_latest_input(), 3); } } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 749ca43..c40a3c2 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -1687,7 +1687,7 @@ impl LinuxClient for X11Client { fn is_cursor_visible(&self) -> bool { let state = self.0.borrow(); - state.cursor_hidden_window != state.mouse_focused_window + is_cursor_visible(state.cursor_hidden_window, state.mouse_focused_window) } fn open_uri(&self, uri: &str) { @@ -2053,7 +2053,8 @@ impl X11ClientState { if let Some(cursor) = self.invisible_cursor_cache { return Some(cursor); } - let cursor = create_invisible_cursor(&self.xcb_connection) + let root = self.xcb_connection.setup().roots[self.x_root_index].root; + let cursor = create_invisible_cursor(&self.xcb_connection, root) .context("X11: error while creating invisible cursor") .log_err()?; self.invisible_cursor_cache = Some(cursor); @@ -2497,11 +2498,21 @@ fn make_scroll_wheel_event( } } +fn is_cursor_visible( + cursor_hidden_window: Option, + mouse_focused_window: Option, +) -> bool { + !matches!( + (cursor_hidden_window, mouse_focused_window), + (Some(hidden_window), Some(focused_window)) if hidden_window == focused_window + ) +} + fn create_invisible_cursor( connection: &XCBConnection, + root: xproto::Window, ) -> anyhow::Result { let empty_pixmap = connection.generate_id()?; - let root = connection.setup().roots[0].root; connection.create_pixmap(1, empty_pixmap, root, 1, 1)?; let cursor = connection.generate_id()?; @@ -2796,3 +2807,17 @@ fn xkb_state_for_key_event(xkb: &xkbc::State, event_state: xproto::KeyButMask) - key_event_state } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_is_visible_unless_hidden_window_is_focused_window() { + assert!(is_cursor_visible(None, None)); + assert!(is_cursor_visible(Some(1), None)); + assert!(is_cursor_visible(None, Some(1))); + assert!(is_cursor_visible(Some(1), Some(2))); + assert!(!is_cursor_visible(Some(1), Some(1))); + } +} diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index 270d993..52e9eac 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -792,7 +792,16 @@ fn slot_font_id( ) -> FontId { match slot { None => primary, - Some(ix) => fallback_chain[ix].0, + Some(ix) => fallback_chain.get(ix).map_or_else( + || { + debug_assert!( + false, + "fallback slot {ix} must be present in the fallback chain" + ); + primary + }, + |(font_id, _)| *font_id, + ), } } diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index 60e1d3b..d3f63ab 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -1039,7 +1039,7 @@ fn fs_shadow(input: ShadowVarying) -> @location(0) vec4 { if (shadow.inset != 0u) { // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. - alpha = 1.0 - alpha; + alpha = saturate(1.0 - alpha); let element_distance = quad_sdf(input.position.xy, shadow.element_bounds, shadow.element_corner_radii); alpha *= saturate(0.5 - element_distance); diff --git a/crates/util/src/command/darwin.rs b/crates/util/src/command/darwin.rs index 77a0ce1..728b206 100644 --- a/crates/util/src/command/darwin.rs +++ b/crates/util/src/command/darwin.rs @@ -15,6 +15,9 @@ use std::process::{ExitStatus, Output}; use std::ptr; use std::sync::{Mutex, MutexGuard}; +// Coordinates this module's `pipe()` + FIOCLEX setup with its `posix_spawn` +// path. Darwin lacks `pipe2(O_CLOEXEC)`, so unrelated spawners that do not take +// this lock can still inherit pipe fds in the small pre-FIOCLEX window. static FD_SPAWN_LOCK: Mutex<()> = Mutex::new(()); fn fd_spawn_lock() -> MutexGuard<'static, ()> { @@ -545,7 +548,7 @@ fn create_pipe_locked() -> io::Result<(libc::c_int, libc::c_int)> { // Set close-on-exec on both ends of the pipe. // - // Without this, unrelated spawns elsewhere in the process (e.g. + // Once set, this prevents unrelated spawns elsewhere in the process (e.g. // `smol::process` or `async_process`, which on Apple platforms use // `posix_spawn` *without* `POSIX_SPAWN_CLOEXEC_DEFAULT`) would inherit // these file descriptors and keep the pipes open even after we drop our From 2c283c253d8fe7006270a5be7523fdc31e666eba Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 12:06:54 -0700 Subject: [PATCH 15/22] test: document scheduler weak wakeup ownership --- crates/gpui/src/platform_scheduler.rs | 2 + crates/scheduler/src/executor.rs | 3 + crates/scheduler/src/test_scheduler.rs | 4 + crates/scheduler/src/tests.rs | 139 +++++++++++++++++++++++++ 4 files changed, 148 insertions(+) diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index ec9039f..1d5422d 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -44,6 +44,8 @@ impl PlatformScheduler { pub fn foreground_executor(self: &Arc) -> LocalExecutor { let session_id = self.next_session_id(); + // Detached local tasks can leave runnables or wakers behind; scheduling + // must not keep the platform scheduler alive after callers drop it. let scheduler = Arc::downgrade(self); LocalExecutor::new(session_id, self.clone(), move |runnable| { if let Some(scheduler) = scheduler.upgrade() { diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 36b062f..74fec9e 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -174,6 +174,9 @@ impl BackgroundExecutor { F: Future + Send + 'static, F::Output: Send + 'static, { + // Keep the schedule callback from retaining the scheduler through + // detached tasks, queued runnables, or pending wakers after callers + // intentionally drop the scheduler. let scheduler = Arc::downgrade(&self.scheduler); let location = Location::caller(); let (runnable, task) = async_task::Builder::new() diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index bf6533d..86922f2 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -161,6 +161,8 @@ impl TestScheduler { /// Create a local executor for this scheduler. pub fn foreground(self: &Arc) -> LocalExecutor { let session_id = self.allocate_session_id(); + // Detached local tasks can leave runnables or wakers behind; scheduling + // must not keep this test scheduler alive after callers drop it. let scheduler = Arc::downgrade(self); LocalExecutor::new(session_id, self.clone(), move |runnable| { if let Some(scheduler) = scheduler.upgrade() { @@ -653,6 +655,8 @@ impl Scheduler for TestScheduler { >, ) -> Task> { let session_id = self.allocate_session_id(); + // Dedicated local tasks use the same weak scheduling contract as + // foreground tasks so detached children cannot retain the scheduler. let scheduler = Arc::downgrade(&self); let executor = LocalExecutor::new(session_id, self, move |runnable| { if let Some(scheduler) = scheduler.upgrade() { diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index 0a5711f..ab39fd3 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -72,6 +72,82 @@ fn test_scheduler_drops_with_stalled_detached_background_task() { drop(sender); } +#[test] +fn test_scheduler_drops_with_queued_detached_foreground_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + + scheduler.foreground().spawn(async {}).detach(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); +} + +#[test] +fn test_scheduler_drops_with_queued_detached_background_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + + scheduler.background().spawn(async {}).detach(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); +} + +#[test] +fn test_detached_foreground_task_wakes_after_executor_handle_drop() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let completed = Rc::new(Cell::new(false)); + let (sender, receiver) = oneshot::channel::<()>(); + + let foreground = scheduler.foreground(); + foreground + .spawn({ + let completed = completed.clone(); + async move { + receiver.await.ok(); + completed.set(true); + } + }) + .detach(); + drop(foreground); + + scheduler.run(); + assert!(!completed.get()); + + sender.send(()).unwrap(); + scheduler.run(); + assert!(completed.get()); +} + +#[test] +fn test_detached_background_task_wakes_after_executor_handle_drop() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let completed = Arc::new(AtomicBool::new(false)); + let (sender, receiver) = oneshot::channel::<()>(); + + let background = scheduler.background(); + background + .spawn({ + let completed = completed.clone(); + async move { + receiver.await.ok(); + completed.store(true, Ordering::SeqCst); + } + }) + .detach(); + drop(background); + + scheduler.run(); + assert!(!completed.load(Ordering::SeqCst)); + + sender.send(()).unwrap(); + scheduler.run(); + assert!(completed.load(Ordering::SeqCst)); +} + #[test] fn test_foreground_ordering() { let mut traces = HashSet::new(); @@ -926,3 +1002,66 @@ fn test_spawn_dedicated_detached_child_runs_after_root_completes() { "detached child must complete after the root, not be cancelled with it" ); } + +#[test] +fn test_scheduler_drops_with_stalled_spawn_dedicated_detached_child() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + let (sender, receiver) = oneshot::channel::<()>(); + + let task = scheduler + .background() + .spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + receiver.await.ok(); + }) + .detach(); + }); + + scheduler.run(); + assert!(task.is_ready()); + drop(task); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); + drop(sender); +} + +#[test] +fn test_spawn_dedicated_detached_child_wakes_after_root_task_completes() { + use parking_lot::Mutex; + + let child_ran = TestScheduler::once(async |scheduler| { + let child_ran = Arc::new(Mutex::new(false)); + let (resume_tx, resume_rx) = oneshot::channel::<()>(); + + let task = { + let child_ran = child_ran.clone(); + scheduler + .background() + .spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + let _ = resume_rx.await; + *child_ran.lock() = true; + }) + .detach(); + }) + }; + + task.await; + scheduler.run(); + assert!(!*child_ran.lock()); + + let _ = resume_tx.send(()); + scheduler.run(); + + *child_ran.lock() + }); + + assert!( + child_ran, + "detached dedicated child must wake after the root task has completed" + ); +} From 36d5d16947ec76863371075075b5bbf5150a194a Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 12:16:36 -0700 Subject: [PATCH 16/22] docs: clarify text macro id semantics --- crates/gpui/src/elements/text.rs | 50 ++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 15caaa6..59c87bb 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -44,7 +44,8 @@ impl Text { self.id.as_ref() } - /// Produce a new [`Text`] with the given `id`. + /// Produce a new [`Text`] with the given `id`, replacing any default + /// macro-generated ID. pub fn with_id(mut self, id: impl Into) -> Self { self.id = Some(id.into()); self @@ -90,6 +91,12 @@ pub const fn __hash_text_macro_location_unstable_do_not_use(s: &'static str) -> } /// Create a new accessible [`Text`] element. +/// +/// The `text!("...")` form assigns an ID derived from the macro call site +/// (`file!`, `line!`, and `column!`). This gives one call site a stable ID +/// across frames, but repeated calls through the same helper or loop body will +/// reuse that ID. When rendering repeated text elements, pass a unique ID with +/// `text!(id = ..., ...)` or [`Text::with_id`]. #[macro_export] macro_rules! text { (id = $id:expr, $text:expr) => {{ $crate::Text::new($id.into(), $text.into()) }}; @@ -1112,9 +1119,11 @@ impl IntoElement for InteractiveText { #[cfg(test)] mod tests { + use crate::{ElementId, SharedString, Text}; + #[test] fn test_into_element_for() { - use crate::{ParentElement as _, SharedString, div}; + use crate::{ParentElement as _, div}; use std::borrow::Cow; let _ = div().child("static str"); @@ -1122,4 +1131,41 @@ mod tests { let _ = div().child(Cow::Borrowed("Cow")); let _ = div().child(SharedString::from("SharedString")); } + + #[test] + fn text_macro_default_id_is_source_location_based() { + fn text_from_same_call_site() -> ElementId { + crate::text!("item").id().cloned().unwrap() + } + + let first = text_from_same_call_site(); + let second = text_from_same_call_site(); + let separate_call_site = crate::text!("item").id().cloned().unwrap(); + + assert_eq!(first, second); + assert_ne!(first, separate_call_site); + } + + #[test] + fn text_macro_accepts_explicit_ids_for_repeated_text() { + let first = crate::text!(id = ("item", 0usize), "item"); + let second = crate::text!(id = ("item", 1usize), "item"); + let overridden = + Text::new_inaccessible(SharedString::from("item")).with_id(("item", 2usize)); + + assert_eq!( + first.id(), + Some(&ElementId::from((SharedString::new_static("item"), 0usize))) + ); + assert_eq!( + second.id(), + Some(&ElementId::from((SharedString::new_static("item"), 1usize))) + ); + assert_eq!( + overridden.id(), + Some(&ElementId::from((SharedString::new_static("item"), 2usize))) + ); + assert_ne!(first.id(), second.id()); + assert_ne!(second.id(), overridden.id()); + } } From 833c3bcf5ae41b8fc14b91e0792a0714eb3698c5 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 12:41:36 -0700 Subject: [PATCH 17/22] fix: isolate macos accesskit adapter --- crates/gpui_macos/src/window.rs | 81 +++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 45358d8..1801bc2 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -48,7 +48,8 @@ use parking_lot::Mutex; use raw_window_handle as rwh; use smallvec::SmallVec; use std::{ - cell::Cell, + cell::{Cell, RefCell}, + collections::HashMap, ffi::{CStr, c_void}, mem, ops::Range, @@ -75,6 +76,11 @@ static mut PANEL_CLASS: *const Class = ptr::null(); static mut VIEW_CLASS: *const Class = ptr::null(); static mut BLURRED_VIEW_CLASS: *const Class = ptr::null(); +thread_local! { + static A11Y_ADAPTERS: RefCell> = + RefCell::new(HashMap::new()); +} + #[allow(non_upper_case_globals)] const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask = NSWindowStyleMask::from_bits_retain(1 << 7); @@ -442,7 +448,6 @@ struct MacWindowState { activated_least_once: bool, closed: Arc, cursor_visible: Arc, - accesskit_adapter: Option, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, } @@ -594,6 +599,10 @@ impl MacWindowState { } } +// AppKit callbacks and GPUI foreground tasks access this state on the main +// thread. Background tasks only hold the mutex long enough to check counters and +// call sendable callbacks. Main-thread-only objects, such as AccessKit's macOS +// adapter, must stay out of this struct. unsafe impl Send for MacWindowState {} pub(crate) struct MacWindow(Arc>); @@ -770,7 +779,6 @@ impl MacWindow { activated_least_once: false, closed: Arc::new(AtomicBool::new(false)), cursor_visible, - accesskit_adapter: None, sheet_parent: None, }))); @@ -1009,6 +1017,7 @@ impl Drop for MacWindow { let window = this.native_window; let sheet_parent = this.sheet_parent.take(); this.display_link.take(); + remove_a11y_adapter(window); unsafe { this.native_window.setDelegate_(nil); } @@ -1739,8 +1748,7 @@ impl PlatformWindow for MacWindow { } fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { - let mut lock = self.0.lock(); - + let window = self.0.lock().native_window; let activation_handler = A11yActivationHandler { callback: callbacks.activation, }; @@ -1748,22 +1756,19 @@ impl PlatformWindow for MacWindow { let adapter = unsafe { accesskit_macos::SubclassingAdapter::for_window( - lock.native_window as *mut c_void, + window as *mut c_void, activation_handler, action_handler, ) }; - lock.accesskit_adapter = Some(adapter); + set_a11y_adapter(window, adapter); } fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { - let events = { - let mut lock = self.0.lock(); - lock.accesskit_adapter - .as_mut() - .and_then(|adapter| adapter.update_if_active(|| tree_update)) - }; + let window = self.0.lock().native_window; + let events = + with_a11y_adapter(window, |adapter| adapter.update_if_active(|| tree_update)).flatten(); if let Some(events) = events { events.raise(); } @@ -1851,10 +1856,47 @@ unsafe fn get_window_state(object: &Object) -> Arc> { unsafe fn drop_window_state(object: &Object) { unsafe { let raw: *mut c_void = *object.get_ivar(WINDOW_STATE_IVAR); - Arc::from_raw(raw as *mut Mutex); + let window_state = Arc::from_raw(raw as *mut Mutex); + remove_a11y_adapter(window_state.lock().native_window); } } +fn a11y_adapter_key(window: id) -> usize { + window as usize +} + +fn set_a11y_adapter(window: id, adapter: accesskit_macos::SubclassingAdapter) { + debug_assert_main_thread(); + A11Y_ADAPTERS.with_borrow_mut(|adapters| { + adapters.insert(a11y_adapter_key(window), adapter); + }); +} + +fn remove_a11y_adapter(window: id) { + debug_assert_main_thread(); + A11Y_ADAPTERS.with_borrow_mut(|adapters| { + // This is intentionally idempotent: both MacWindow::drop and Objective-C + // dealloc paths can observe teardown, depending on who releases last. + adapters.remove(&a11y_adapter_key(window)); + }); +} + +fn with_a11y_adapter( + window: id, + f: impl FnOnce(&mut accesskit_macos::SubclassingAdapter) -> T, +) -> Option { + debug_assert_main_thread(); + A11Y_ADAPTERS.with_borrow_mut(|adapters| adapters.get_mut(&a11y_adapter_key(window)).map(f)) +} + +fn debug_assert_main_thread() { + let is_main_thread: BOOL = unsafe { msg_send![class!(NSThread), isMainThread] }; + debug_assert_eq!( + is_main_thread, YES, + "macOS accessibility adapters must stay on the main thread" + ); +} + extern "C" fn yes(_: &Object, _: Sel) -> BOOL { YES } @@ -2299,12 +2341,11 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let executor = lock.foreground_executor.clone(); drop(lock); - let a11y_events = { - let mut lock = window_state.lock(); - lock.accesskit_adapter - .as_mut() - .and_then(|adapter| adapter.update_view_focus_state(is_active)) - }; + let native_window = window_state.lock().native_window; + let a11y_events = with_a11y_adapter(native_window, |adapter| { + adapter.update_view_focus_state(is_active) + }) + .flatten(); if let Some(events) = a11y_events { events.raise(); } From 15c03cdc5767138614e71a05dff99bd1653dfb53 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 12:53:35 -0700 Subject: [PATCH 18/22] fix: address gpui review followups --- crates/gpui/src/elements/list.rs | 118 +++++++++++++++++------ crates/gpui/src/elements/uniform_list.rs | 83 +++++++++++++++- crates/gpui_macos/src/window.rs | 8 +- 3 files changed, 169 insertions(+), 40 deletions(-) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 9d1e5e0..3f6c50a 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -61,6 +61,7 @@ impl std::fmt::Debug for ListState { struct StateInner { last_layout_bounds: Option>, + last_layout_scroll_top: Option, last_padding: Option>, items: SumTree, logical_scroll_top: Option, @@ -314,6 +315,7 @@ impl ListState { pub fn new(item_count: usize, alignment: ListAlignment, overdraw: Pixels) -> Self { let this = Self(Rc::new(RefCell::new(StateInner { last_layout_bounds: None, + last_layout_scroll_top: None, last_padding: None, items: SumTree::default(), logical_scroll_top: None, @@ -347,6 +349,7 @@ impl ListState { state.reset = true; state.measuring_behavior.reset(); state.logical_scroll_top = None; + state.last_layout_scroll_top = None; state.scrollbar_drag_start_height = None; state.items.summary().count }; @@ -439,6 +442,7 @@ impl ListState { new_items }; state.items = new_items; + state.last_layout_scroll_top = None; state.measuring_behavior.reset(); } @@ -501,6 +505,7 @@ impl ListState { new_items.append(old_items.suffix(), ()); drop(old_items); state.items = new_items; + state.last_layout_scroll_top = None; if let Some(ListOffset { item_ix, @@ -557,6 +562,7 @@ impl ListState { item_ix: cursor.start().count, offset_in_item: new_pixel_offset - cursor.start().height, }); + state.last_layout_scroll_top = None; } /// Scroll the list to the very end, past the last item. @@ -570,6 +576,7 @@ impl ListState { }), ListAlignment::Bottom => None, }; + state.last_layout_scroll_top = None; } /// Set whether the list should auto-follow the tail. @@ -588,6 +595,7 @@ impl ListState { }), ListAlignment::Bottom => None, }; + state.last_layout_scroll_top = None; } } } @@ -611,6 +619,7 @@ impl ListState { } state.logical_scroll_top = Some(scroll_top); + state.last_layout_scroll_top = None; } /// Scroll the list to the given item, such that the item is fully visible. @@ -644,6 +653,7 @@ impl ListState { } state.logical_scroll_top = Some(scroll_top); + state.last_layout_scroll_top = None; } /// Get the bounds for the given item in window coordinates, if it's @@ -651,29 +661,7 @@ impl ListState { pub fn bounds_for_item(&self, ix: usize) -> Option> { let state = &*self.0.borrow(); - let bounds = state.last_layout_bounds.unwrap_or_default(); - let scroll_top = state.logical_scroll_top(); - if ix < scroll_top.item_ix { - return None; - } - - let mut cursor = state.items.cursor::>(()); - cursor.seek(&Count(scroll_top.item_ix), Bias::Right); - - let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item; - - cursor.seek_forward(&Count(ix), Bias::Right); - if let Some(&ListItem::Measured { size, .. }) = cursor.item() { - let &Dimensions(Count(count), Height(top), _) = cursor.start(); - if count == ix { - let top = bounds.top() + top - scroll_top; - return Some(Bounds::from_corners( - point(bounds.left(), top), - point(bounds.right(), top + size.height), - )); - } - } - None + state.bounds_for_item_at_scroll_top(ix, state.logical_scroll_top()) } /// Call this method when the user starts dragging the scrollbar. @@ -743,43 +731,74 @@ impl ListState { /// Returns whether the item is entirely above the viewport, or `None` if /// the list has not measured enough layout to know. pub fn item_is_above_viewport(&self, ix: usize) -> Option { - let viewport_bounds = self.viewport_bounds(); + let state = self.0.borrow(); + let viewport_bounds = state.last_layout_bounds.unwrap_or_default(); if viewport_bounds.size.height == px(0.0) { return None; } - let scroll_top = self.logical_scroll_top(); - if scroll_top.item_ix < self.item_count() && ix < scroll_top.item_ix { + let scroll_top = state.last_layout_scroll_top?; + if scroll_top.item_ix < state.items.summary().count && ix < scroll_top.item_ix { // Rows before the logical scroll top have no item bounds, but // their position relative to the viewport is known from scroll state. return Some(true); } - let item_bounds = self.bounds_for_item(ix)?; + let item_bounds = state.bounds_for_item_at_scroll_top(ix, scroll_top)?; Some(item_bounds.bottom() <= viewport_bounds.top()) } /// Returns whether the item is entirely below the viewport, or `None` if /// the list has not measured enough layout to know. pub fn item_is_below_viewport(&self, ix: usize) -> Option { - let viewport_bounds = self.viewport_bounds(); + let state = self.0.borrow(); + let viewport_bounds = state.last_layout_bounds.unwrap_or_default(); if viewport_bounds.size.height == px(0.0) { return None; } - let scroll_top = self.logical_scroll_top(); - if scroll_top.item_ix < self.item_count() && ix < scroll_top.item_ix { + let scroll_top = state.last_layout_scroll_top?; + if scroll_top.item_ix < state.items.summary().count && ix < scroll_top.item_ix { // Rows before the logical scroll top have no item bounds, but // their position relative to the viewport is known from scroll state. return Some(false); } - let item_bounds = self.bounds_for_item(ix)?; + let item_bounds = state.bounds_for_item_at_scroll_top(ix, scroll_top)?; Some(item_bounds.top() >= viewport_bounds.bottom()) } } impl StateInner { + fn bounds_for_item_at_scroll_top( + &self, + ix: usize, + scroll_top: ListOffset, + ) -> Option> { + let bounds = self.last_layout_bounds.unwrap_or_default(); + if ix < scroll_top.item_ix { + return None; + } + + let mut cursor = self.items.cursor::>(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + + let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item; + + cursor.seek_forward(&Count(ix), Bias::Right); + if let Some(&ListItem::Measured { size, .. }) = cursor.item() { + let &Dimensions(Count(count), Height(top), _) = cursor.start(); + if count == ix { + let top = bounds.top() + top - scroll_top; + return Some(Bounds::from_corners( + point(bounds.left(), top), + point(bounds.right(), top + size.height), + )); + } + } + None + } + fn max_scroll_offset(&self) -> Pixels { let bounds = self.last_layout_bounds.unwrap_or_default(); let height = self @@ -835,6 +854,7 @@ impl StateInner { offset_in_item, }); } + self.last_layout_scroll_top = None; if delta.y > px(0.) { self.follow_state.stop_following(); @@ -1264,6 +1284,7 @@ impl StateInner { offset_in_item: px(0.), }); } + self.last_layout_scroll_top = None; return; } @@ -1283,6 +1304,7 @@ impl StateInner { offset_in_item, }); } + self.last_layout_scroll_top = None; } } @@ -1440,6 +1462,7 @@ impl Element for List { }; state.last_layout_bounds = Some(bounds); + state.last_layout_scroll_top = Some(layout.scroll_top); state.last_padding = Some(padding); ListPrepaintState { hitbox, layout } } @@ -1786,6 +1809,39 @@ mod test { assert_eq!(state.item_is_below_viewport(0), None); } + #[gpui::test] + fn test_item_viewport_queries_use_resolved_bottom_aligned_scroll_top(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + const ITEMS: usize = 10; + const ITEM_HEIGHT: f32 = 20.0; + + let state = ListState::new(ITEMS, crate::ListAlignment::Bottom, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(ITEM_HEIGHT)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(40.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + assert_eq!(state.logical_scroll_top().item_ix, ITEMS); + assert_eq!(state.item_is_above_viewport(7), Some(true)); + assert_eq!(state.item_is_below_viewport(7), Some(false)); + assert_eq!(state.item_is_above_viewport(8), Some(false)); + assert_eq!(state.item_is_below_viewport(8), Some(false)); + assert_eq!(state.item_is_above_viewport(9), Some(false)); + assert_eq!(state.item_is_below_viewport(9), Some(false)); + } + #[gpui::test] fn test_measure_all_after_width_change(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index c439c90..0e423a0 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -245,7 +245,11 @@ impl UniformListScrollHandle { return None; } let offset = state.base_handle.offset(); - Some(-offset.y >= max_offset.height) + Some(if state.y_flipped { + offset.y == px(0.) + } else { + -offset.y >= max_offset.height + }) } /// Scroll to the bottom of the list. @@ -719,7 +723,82 @@ impl InteractiveElement for UniformList { #[cfg(test)] mod test { - use crate::TestAppContext; + use crate::{TestAppContext, UniformListScrollHandle}; + + fn draw_scroll_end_test_list( + cx: &mut TestAppContext, + handle: UniformListScrollHandle, + y_flipped: bool, + ) { + use crate::{ + Context, IntoElement, Render, Window, div, point, prelude::*, px, size, uniform_list, + }; + + struct TestView { + handle: UniformListScrollHandle, + y_flipped: bool, + } + + impl Render for TestView { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + let list = uniform_list("items", 10, |range, _, _| { + range + .map(|ix| div().id(ix).h(px(10.)).child(format!("Item {ix}"))) + .collect() + }) + .track_scroll(&self.handle) + .h(px(30.)); + + if self.y_flipped { + list.y_flipped(true) + } else { + list + } + } + } + + let cx = cx.add_empty_window(); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(30.)), |_, cx| { + cx.new(|_| TestView { handle, y_flipped }) + .into_any_element() + }); + } + + #[gpui::test] + fn test_is_scrolled_to_end(cx: &mut TestAppContext) { + use crate::{UniformListScrollHandle, point, px}; + + let handle = UniformListScrollHandle::new(); + draw_scroll_end_test_list(cx, handle.clone(), false); + + assert_eq!(handle.is_scrolled_to_end(), Some(false)); + + let base_handle = handle.0.borrow().base_handle.clone(); + base_handle.set_offset(point(px(0.), -base_handle.max_offset().height)); + + assert_eq!(handle.is_scrolled_to_end(), Some(true)); + } + + #[gpui::test] + fn test_is_scrolled_to_end_with_flipped_list(cx: &mut TestAppContext) { + use crate::{UniformListScrollHandle, point, px}; + + let handle = UniformListScrollHandle::new(); + draw_scroll_end_test_list(cx, handle.clone(), true); + + let base_handle = handle.0.borrow().base_handle.clone(); + base_handle.set_offset(point(px(0.), -base_handle.max_offset().height)); + + assert_eq!(handle.is_scrolled_to_end(), Some(false)); + + base_handle.set_offset(point(px(0.), px(0.))); + + assert_eq!(handle.is_scrolled_to_end(), Some(true)); + } #[gpui::test] fn test_scroll_strategy_nearest(cx: &mut TestAppContext) { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 1801bc2..60dfe11 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -2083,13 +2083,7 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { // AppKit unhides the cursor on the next mouse movement; mirror that here. if matches!( event, - PlatformInput::MouseMove(_) - | PlatformInput::MouseDown(_) - | PlatformInput::MouseUp(_) - | PlatformInput::MousePressure(_) - | PlatformInput::MouseExited(_) - | PlatformInput::ScrollWheel(_) - | PlatformInput::Pinch(_) + PlatformInput::MouseMove(_) | PlatformInput::MouseExited(_) ) { lock.cursor_visible.store(true, Ordering::Relaxed); } From 0096caf34342260a4af58ea615354b575fd571ac Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 13:46:34 -0700 Subject: [PATCH 19/22] fix: address upstream sync followups --- crates/gpui/examples/a11y.rs | 36 +++++++++---------- crates/gpui/src/_accessibility.rs | 2 +- crates/gpui/src/elements/list.rs | 31 +++++++++++++++- crates/gpui/src/platform.rs | 6 +++- crates/gpui/src/text_system/line_wrapper.rs | 25 +++++++++++-- crates/gpui/src/window/a11y.rs | 21 ++++++----- crates/gpui/tests/cursor_style.rs | 15 ++++++++ .../gpui_linux/src/linux/accesskit_shims.rs | 8 ++--- .../gpui_linux/src/linux/headless/client.rs | 6 ++++ crates/gpui_linux/src/linux/platform.rs | 6 ++++ crates/gpui_linux/src/linux/wayland.rs | 6 ++++ crates/gpui_linux/src/linux/wayland/client.rs | 35 +++++++++++++----- crates/gpui_linux/src/linux/wayland/window.rs | 4 ++- crates/gpui_linux/src/linux/x11/client.rs | 11 +++++- crates/gpui_linux/src/linux/x11/window.rs | 4 ++- crates/gpui_macos/src/platform.rs | 25 +++++++++++++ crates/gpui_macros/src/styles.rs | 7 ++++ crates/gpui_web/src/platform.rs | 1 + crates/gpui_wgpu/src/wgpu_renderer.rs | 1 + crates/gpui_windows/src/events.rs | 12 +++---- crates/gpui_windows/src/platform.rs | 4 +-- crates/gpui_windows/src/util.rs | 1 + crates/scheduler/src/tests.rs | 28 +++++++++++---- 23 files changed, 232 insertions(+), 63 deletions(-) create mode 100644 crates/gpui/tests/cursor_style.rs diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs index 5478a02..2418aaa 100644 --- a/crates/gpui/examples/a11y.rs +++ b/crates/gpui/examples/a11y.rs @@ -3,8 +3,8 @@ //! Run with: `cargo run -p gpui --example a11y`. use gpui::{ - AccessibleAction, App, Context, FocusHandle, KeyBinding, Role, SharedString, Toggled, Window, - WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, text, + AccessibleAction, App, Context, FocusHandle, KeyBinding, Role, SharedString, Text, Toggled, + Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, text, }; use gpui_platform::application; @@ -30,6 +30,9 @@ impl A11yDemo { impl Render for A11yDemo { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tasks = ["Write code", "Run tests", "Ship it"]; + let task_count = tasks.len(); + div() .id("root") .role(Role::Application) @@ -52,7 +55,7 @@ impl Render for A11yDemo { .aria_label("Accessibility Demo") .text_xl() .font_weight(gpui::FontWeight::BOLD) - .child(text!("Accessibility Demo")), + .child(Text::new_inaccessible("Accessibility Demo".into())), ) .child( div() @@ -166,22 +169,17 @@ impl Render for A11yDemo { .flex() .flex_col() .gap_1() - .children( - ["Write code", "Run tests", "Ship it"] - .iter() - .enumerate() - .map(|(i, label)| { - div() - .id(("task", i)) - .role(Role::ListItem) - .aria_label(SharedString::from(*label)) - .aria_position_in_set(i + 1) - .aria_size_of_set(3) - .py_1() - .px_2() - .child(text!(format!("{}. {}", i + 1, label))) - }), - ), + .children(tasks.into_iter().enumerate().map(|(i, label)| { + div() + .id(("task", i)) + .role(Role::ListItem) + .aria_label(SharedString::from(label)) + .aria_position_in_set(i + 1) + .aria_size_of_set(task_count) + .py_1() + .px_2() + .child(text!(format!("{}. {}", i + 1, label))) + })), ) } } diff --git a/crates/gpui/src/_accessibility.rs b/crates/gpui/src/_accessibility.rs index f20a4ab..3962bca 100644 --- a/crates/gpui/src/_accessibility.rs +++ b/crates/gpui/src/_accessibility.rs @@ -14,7 +14,7 @@ //! GPUI integrates with [AccessKit] to provide programmatic accessibility //! features (referred to as simply "accessibility" for the rest of this guide). //! -//! A minimal example can be found in the `examples/a11y` directory. +//! A minimal example can be found in `examples/a11y.rs`. //! //! ## Background //! diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 3f6c50a..8f8208a 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -804,7 +804,8 @@ impl StateInner { let height = self .scrollbar_drag_start_height .unwrap_or_else(|| self.items.summary().height); - (height - bounds.size.height).max(px(0.)) + let padding = self.last_padding.unwrap_or_default(); + (height + padding.top + padding.bottom - bounds.size.height).max(px(0.)) } fn visible_range( @@ -1872,6 +1873,34 @@ mod test { assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); } + #[gpui::test] + fn test_scrollbar_max_offset_includes_padding(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(1, crate::ListAlignment::Top, px(500.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(500.)).w_full().into_any() + }) + .py_5() + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + assert_eq!(state.max_offset_for_scrollbar().y, px(340.)); + + state.set_offset_from_scrollbar(point(px(0.), px(-340.))); + assert_eq!(state.scroll_px_offset_for_scrollbar().y, px(-340.)); + } + #[gpui::test] fn test_remeasure(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 3f96eb3..a306fc1 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -350,7 +350,7 @@ impl From for u64 { impl TryFrom for u32 { type Error = std::num::TryFromIntError; - fn try_from(id: DisplayId) -> Result { + fn try_from(id: DisplayId) -> std::result::Result { Self::try_from(id.0) } } @@ -1795,6 +1795,10 @@ pub enum CursorStyle { /// A cursor indicating that the operation will result in a context menu /// corresponds to the CSS cursor value `context-menu` ContextualMenu, + + /// Hide the cursor + /// corresponds to the CSS cursor value `none` + None, } /// A clipboard item that should be copied to the clipboard diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 23af24e..b0068b2 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -505,7 +505,7 @@ impl LineWrapper { } fn wrapped_line_count(&mut self, text: &str, wrap_width: Pixels) -> usize { - text.split('\n') + text.split_terminator('\n') .map(|line| { self.wrap_line(&[LineFragment::text(line)], wrap_width) .count() @@ -520,7 +520,7 @@ impl LineWrapper { text: &str, wrap_width: Pixels, ) -> usize { - let mut lines = text.split('\n'); + let mut lines = text.split_terminator('\n'); let Some(first_line) = lines.next() else { return 0; }; @@ -815,7 +815,7 @@ mod tests { } fn wrapped_line_count(wrapper: &mut LineWrapper, text: &str, wrap_width: Pixels) -> usize { - text.split('\n') + text.split_terminator('\n') .map(|line| { wrapper .wrap_line(&[LineFragment::text(line)], wrap_width) @@ -1445,6 +1445,25 @@ mod tests { assert!(matches!(result_runs, Cow::Borrowed(_))); } + #[test] + fn test_multiline_start_truncation_trailing_newline() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld\n"; + let runs = generate_test_runs(&[text.len()]); + + let (result, result_runs) = wrapper.truncate_wrapped_line( + text.into(), + px(500.), + 2, + "…", + &runs, + TruncateFrom::Start, + ); + + assert_eq!(result, text); + assert!(matches!(result_runs, Cow::Borrowed(_))); + } + #[test] fn test_multiline_start_truncation_updates_runs() { let mut wrapper = build_wrapper(); diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 3c0b120..50a959f 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -391,17 +391,20 @@ impl A11yNodeBuilder { if let Some(current_node) = self.nodes_stack.last() { let mut pending = current_node.children().to_vec(); - while let Some(child_id) = pending.pop() { - if !pruned_ids.insert(child_id) { - continue; - } - - if let Some((_, child_node)) = self + if !pending.is_empty() { + let emitted_nodes_by_id = self .all_nodes .iter() - .find(|(node_id, _)| *node_id == child_id) - { - pending.extend(child_node.children().iter().copied()); + .map(|(node_id, node)| (*node_id, node)) + .collect::>(); + while let Some(child_id) = pending.pop() { + if !pruned_ids.insert(child_id) { + continue; + } + + if let Some(child_node) = emitted_nodes_by_id.get(&child_id) { + pending.extend(child_node.children().iter().copied()); + } } } } diff --git a/crates/gpui/tests/cursor_style.rs b/crates/gpui/tests/cursor_style.rs new file mode 100644 index 0000000..f7081ec --- /dev/null +++ b/crates/gpui/tests/cursor_style.rs @@ -0,0 +1,15 @@ +use gpui::{CursorStyle, Styled, div}; + +#[test] +fn cursor_none_helper_sets_none_cursor_style() { + let mut element = div().cursor_none(CursorStyle::Arrow); + + assert_eq!(element.style().mouse_cursor, Some(CursorStyle::None)); +} + +#[test] +fn cursor_style_accepts_none() { + let mut element = div().cursor(CursorStyle::None); + + assert_eq!(element.style().mouse_cursor, Some(CursorStyle::None)); +} diff --git a/crates/gpui_linux/src/linux/accesskit_shims.rs b/crates/gpui_linux/src/linux/accesskit_shims.rs index 11afc48..375ba62 100644 --- a/crates/gpui_linux/src/linux/accesskit_shims.rs +++ b/crates/gpui_linux/src/linux/accesskit_shims.rs @@ -8,13 +8,13 @@ impl accesskit::ActivationHandler for TrivialActivationHandler { } } -pub(crate) struct TrivialActionHandler( - pub(crate) Box, -); +pub(crate) struct TrivialActionHandler { + pub(crate) callback: Box, +} impl accesskit::ActionHandler for TrivialActionHandler { fn do_action(&mut self, request: accesskit::ActionRequest) { - (self.0)(request); + (self.callback)(request); } } diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index 56cc9e8..4c99054 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -99,6 +99,12 @@ impl LinuxClient for HeadlessClient { fn set_cursor_style(&self, _style: CursorStyle) {} + fn hide_cursor_until_mouse_moves(&self) {} + + fn is_cursor_visible(&self) -> bool { + false + } + fn open_uri(&self, _uri: &str) {} fn reveal_path(&self, _path: std::path::PathBuf) {} diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index e536d99..c70a2ae 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -824,6 +824,12 @@ pub(super) fn cursor_style_to_icon_names(style: CursorStyle) -> &'static [&'stat CursorStyle::DragLink => &["alias"], CursorStyle::DragCopy => &["copy"], CursorStyle::ContextualMenu => &["context-menu"], + CursorStyle::None => { + #[cfg(debug_assertions)] + panic!("CursorStyle::None should be handled separately in the client"); + #[cfg(not(debug_assertions))] + &[DEFAULT_CURSOR_ICON_NAME] + } } } diff --git a/crates/gpui_linux/src/linux/wayland.rs b/crates/gpui_linux/src/linux/wayland.rs index 3e90688..aa1e797 100644 --- a/crates/gpui_linux/src/linux/wayland.rs +++ b/crates/gpui_linux/src/linux/wayland.rs @@ -37,5 +37,11 @@ pub(super) fn to_shape(style: CursorStyle) -> Shape { CursorStyle::DragLink => Shape::Alias, CursorStyle::DragCopy => Shape::Copy, CursorStyle::ContextualMenu => Shape::ContextMenu, + CursorStyle::None => { + #[cfg(debug_assertions)] + panic!("CursorStyle::None should be handled separately in the client"); + #[cfg(not(debug_assertions))] + Shape::Default + } } } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index ae23a57..d0bc4f8 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -412,6 +412,16 @@ impl WaylandClientStatePtr { } impl WaylandClientState { + fn hide_cursor(&self) -> bool { + let Some(wl_pointer) = self.wl_pointer.clone() else { + // Seat lost its pointer capability; nothing to hide. + return false; + }; + let serial = self.serial_tracker.get(SerialKind::MouseEnter); + wl_pointer.set_cursor(serial, None, 0, 0); + true + } + fn hide_cursor_until_mouse_moves(&mut self) { if self.cursor_hidden_window.is_some() { return; @@ -420,13 +430,9 @@ impl WaylandClientState { // No surface to apply the hidden cursor to. return; }; - let Some(wl_pointer) = self.wl_pointer.clone() else { - // Seat lost its pointer capability; nothing to hide. - return; - }; - let serial = self.serial_tracker.get(SerialKind::MouseEnter); - wl_pointer.set_cursor(serial, None, 0, 0); - self.cursor_hidden_window = Some(focused_window); + if self.hide_cursor() { + self.cursor_hidden_window = Some(focused_window); + } } fn restore_cursor_after_hide(&mut self) { @@ -434,6 +440,10 @@ impl WaylandClientState { return; } let style = self.cursor_style.unwrap_or(CursorStyle::Arrow); + if style == CursorStyle::None { + self.cursor_hidden_window = None; + return; + } let serial = self.serial_tracker.get(SerialKind::MouseEnter); if let Some(cursor_shape_device) = &self.cursor_shape_device { cursor_shape_device.set_shape(serial, to_shape(style)); @@ -445,6 +455,7 @@ impl WaylandClientState { "wayland: no focused surface to restore cursor style {:?} after hide; cursor may stay invisible", style ); + self.cursor_hidden_window = None; return; }; let Some(wl_pointer) = self.wl_pointer.clone() else { @@ -452,6 +463,7 @@ impl WaylandClientState { "wayland: no wl_pointer to restore cursor style {:?} after hide; cursor may stay invisible", style ); + self.cursor_hidden_window = None; return; }; let scale = focused_window.primary_output_scale(); @@ -834,6 +846,11 @@ impl LinuxClient for WaylandClient { state.cursor_style = Some(style); + if style == CursorStyle::None { + state.hide_cursor(); + return; + } + // Don't clobber the invisible cursor; restore reads back from `cursor_style`. if state.cursor_hidden_window.is_some() { return; @@ -1777,7 +1794,9 @@ impl Dispatch for WaylandClientStatePtr { } state.restore_cursor_after_hide(); if let Some(style) = state.cursor_style { - if let Some(cursor_shape_device) = &state.cursor_shape_device { + if style == CursorStyle::None { + wl_pointer.set_cursor(serial, None, 0, 0); + } else if let Some(cursor_shape_device) = &state.cursor_shape_device { cursor_shape_device.set_shape(serial, to_shape(style)); } else { let scale = window.primary_output_scale(); diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 7d9f971..9654868 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1482,7 +1482,9 @@ impl PlatformWindow for WaylandWindow { let activation_handler = TrivialActivationHandler { callback: callbacks.activation, }; - let action_handler = TrivialActionHandler(callbacks.action); + let action_handler = TrivialActionHandler { + callback: callbacks.action, + }; let deactivation_handler = TrivialDeactivationHandler { callback: callbacks.deactivation, }; diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index c40a3c2..852c3d9 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -1663,7 +1663,12 @@ impl LinuxClient for X11Client { return; } - let Some(cursor) = state.get_cursor_icon(style) else { + let cursor = if style == CursorStyle::None { + state.get_or_create_invisible_cursor() + } else { + state.get_cursor_icon(style) + }; + let Some(cursor) = cursor else { return; }; @@ -2104,6 +2109,10 @@ impl X11ClientState { .get(&hidden_window) .copied() .unwrap_or(CursorStyle::Arrow); + if style == CursorStyle::None { + self.cursor_hidden_window = None; + return; + } let Some(cursor) = self.get_cursor_icon(style) else { log::warn!( "X11: no cursor icon available to restore {:?} after hide; cursor may stay invisible", diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index c884333..caf0f6e 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1882,7 +1882,9 @@ impl PlatformWindow for X11Window { let activation_handler = TrivialActivationHandler { callback: callbacks.activation, }; - let action_handler = TrivialActionHandler(callbacks.action); + let action_handler = TrivialActionHandler { + callback: callbacks.action, + }; let deactivation_handler = TrivialDeactivationHandler { callback: callbacks.deactivation, }; diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index f00882c..df0583e 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -182,6 +182,7 @@ pub(crate) struct MacPlatformState { keyboard_mapper: Rc, /// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose. cursor_visible: Arc, + cursor_hidden_by_style: bool, } impl MacPlatform { @@ -219,6 +220,7 @@ impl MacPlatform { menus: None, keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), + cursor_hidden_by_style: false, })) } @@ -985,7 +987,29 @@ impl Platform for MacPlatform { /// Match cursor style to one of the styles available /// in macOS's [NSCursor](https://developer.apple.com/documentation/appkit/nscursor). fn set_cursor_style(&self, style: CursorStyle) { + let cursor_was_hidden_by_style = { + let mut state = self.0.lock(); + if style == CursorStyle::None { + if state.cursor_hidden_by_style { + return; + } + state.cursor_hidden_by_style = true; + false + } else { + std::mem::replace(&mut state.cursor_hidden_by_style, false) + } + }; + unsafe { + if style == CursorStyle::None { + let _: () = msg_send![class!(NSCursor), hide]; + return; + } + + if cursor_was_hidden_by_style { + let _: () = msg_send![class!(NSCursor), unhide]; + } + let new_cursor: id = match style { CursorStyle::Arrow => msg_send![class!(NSCursor), arrowCursor], CursorStyle::IBeam => msg_send![class!(NSCursor), IBeamCursor], @@ -1020,6 +1044,7 @@ impl Platform for MacPlatform { CursorStyle::DragLink => msg_send![class!(NSCursor), dragLinkCursor], CursorStyle::DragCopy => msg_send![class!(NSCursor), dragCopyCursor], CursorStyle::ContextualMenu => msg_send![class!(NSCursor), contextualMenuCursor], + CursorStyle::None => unreachable!(), }; let old_cursor: id = msg_send![class!(NSCursor), currentCursor]; diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs index 534065d..8f4283d 100644 --- a/crates/gpui_macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -326,6 +326,13 @@ pub fn cursor_style_methods(input: TokenStream) -> TokenStream { self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeLeft); self } + + /// Sets cursor style when hovering over an element to `none`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_none(mut self, _cursor: CursorStyle) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::None); + self + } }; output.into() diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index d117ef6..c965c52 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -373,6 +373,7 @@ impl Platform for WebPlatform { CursorStyle::DragLink => "alias", CursorStyle::DragCopy => "copy", CursorStyle::ContextualMenu => "context-menu", + CursorStyle::None => "none", }; self.last_cursor_css.set(css_cursor); diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 1ad2fdf..46e3438 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -1548,6 +1548,7 @@ impl WgpuRenderer { } for shadow in &mut scene.shadows { shadow.bounds = shadow.bounds - origin; + shadow.element_bounds = shadow.element_bounds - origin; shadow.content_mask.bounds = shadow.content_mask.bounds - origin; } for blur in &mut scene.backdrop_blurs { diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 8a22539..53a96da 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -335,7 +335,7 @@ impl WindowsWindowInner { self.state.hovered.set(false); // The next window's `WM_SETCURSOR` picks its own cursor, so we just clear // the flag for tight `is_cursor_visible()` semantics. - self.state.cursor_visible.store(true, Ordering::Relaxed); + self.state.cursor_visible.store(true, Ordering::Release); if let Some(mut callback) = self.state.callbacks.hovered_status_change.take() { callback(false); self.state @@ -744,7 +744,7 @@ impl WindowsWindowInner { let this = self.clone(); if !activated { - this.state.cursor_visible.store(true, Ordering::Relaxed); + this.state.cursor_visible.store(true, Ordering::Release); } self.executor @@ -774,7 +774,7 @@ impl WindowsWindowInner { fn handle_wm_getobject(&self, wparam: WPARAM, lparam: LPARAM) -> Option { let result = { - let mut a11y = self.state.a11y.borrow_mut(); + let mut a11y = self.state.a11y.try_borrow_mut().ok()?; let a11y = a11y.as_mut()?; a11y.adapter.handle_wm_getobject( accesskit_windows::WPARAM(wparam.0), @@ -1087,7 +1087,7 @@ impl WindowsWindowInner { }); if had_cursor != self.state.current_cursor.get().is_some() { - let cursor = if self.state.cursor_visible.load(Ordering::Relaxed) { + let cursor = if self.state.cursor_visible.load(Ordering::Acquire) { self.state.current_cursor.get() } else { None @@ -1114,7 +1114,7 @@ impl WindowsWindowInner { { return None; } - let cursor = if self.state.cursor_visible.load(Ordering::Relaxed) { + let cursor = if self.state.cursor_visible.load(Ordering::Acquire) { self.state.current_cursor.get() } else { None @@ -1275,7 +1275,7 @@ impl WindowsWindowInner { /// Clear the hidden flag and restore the cursor immediately fn restore_cursor_after_hide(&self) { - if !self.state.cursor_visible.swap(true, Ordering::Relaxed) { + if !self.state.cursor_visible.swap(true, Ordering::AcqRel) { unsafe { SetCursor(self.state.current_cursor.get()); } diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index c98af22..68b8424 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -684,7 +684,7 @@ impl Platform for WindowsPlatform { .inner .state .cursor_visible - .swap(false, Ordering::Relaxed) + .swap(false, Ordering::AcqRel) { return; } @@ -701,7 +701,7 @@ impl Platform for WindowsPlatform { } fn is_cursor_visible(&self) -> bool { - self.inner.state.cursor_visible.load(Ordering::Relaxed) + self.inner.state.cursor_visible.load(Ordering::Acquire) } fn write_to_clipboard(&self, item: ClipboardItem) { diff --git a/crates/gpui_windows/src/util.rs b/crates/gpui_windows/src/util.rs index a883235..dba3813 100644 --- a/crates/gpui_windows/src/util.rs +++ b/crates/gpui_windows/src/util.rs @@ -115,6 +115,7 @@ pub(crate) fn load_cursor(style: CursorStyle) -> Option { CursorStyle::ResizeUpLeftDownRight => (&SIZENWSE, IDC_SIZENWSE), CursorStyle::ResizeUpRightDownLeft => (&SIZENESW, IDC_SIZENESW), CursorStyle::OperationNotAllowed => (&NO, IDC_NO), + CursorStyle::None => return None, _ => (&ARROW, IDC_ARROW), }; Some( diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index ab39fd3..eb3466b 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -247,22 +247,38 @@ fn test_foreground_task_can_hold_mut_borrow_across_await() { TestScheduler::once(async |scheduler| { let foreground = scheduler.foreground(); let (sender, mut receiver) = mpsc::unbounded::<()>(); - let (completion_sender, completion_receiver) = oneshot::channel::<()>(); + let (completion_sender, completion_receiver) = oneshot::channel::(); + let state = Rc::new(RefCell::new(1)); foreground - .spawn(async move { - receiver.next().await; - completion_sender.send(()).ok(); + .spawn({ + let state = state.clone(); + async move { + let mut state = state.borrow_mut(); + *state += 1; + + receiver.next().await; + + *state += 1; + completion_sender.send(*state).ok(); + } }) .detach(); scheduler.run(); + assert!( + state.try_borrow_mut().is_err(), + "foreground task should hold the mutable borrow while suspended" + ); + sender.unbounded_send(()).unwrap(); scheduler.run(); - assert!( - matches!(completion_receiver.now_or_never(), Some(Ok(()))), + assert_eq!( + completion_receiver.now_or_never(), + Some(Ok(3)), "foreground task should resume after receiving from the channel" ); + assert_eq!(*state.borrow(), 3); }); } From 0d4144bd5a70a2d5f0232fdceb1717afe02d3740 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 14:14:21 -0700 Subject: [PATCH 20/22] fix: address upstream review followups --- crates/gpui/src/window/a11y.rs | 605 +----------------- crates/gpui/src/window/a11y/builder.rs | 383 +++++++++++ crates/gpui/src/window/a11y/builder_tests.rs | 232 +++++++ crates/gpui_linux/src/linux/wayland/client.rs | 3 +- crates/gpui_macos/src/platform.rs | 3 +- crates/scheduler/src/scheduler.rs | 30 +- crates/scheduler/src/tests.rs | 29 + 7 files changed, 675 insertions(+), 610 deletions(-) create mode 100644 crates/gpui/src/window/a11y/builder.rs create mode 100644 crates/gpui/src/window/a11y/builder_tests.rs diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 50a959f..8107d61 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -5,12 +5,15 @@ use crate::{App, Bounds, FocusId, GlobalElementId, Pixels, Window}; use accesskit::{Action, NodeId, TreeUpdate}; use collections::{FxHashMap, FxHashSet}; -use smallvec::SmallVec; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; +mod builder; + +pub(crate) use builder::{A11yNodeBuilder, A11yPrepaintSnapshot}; + /// The fixed AccessKit node ID used for the root of every window's a11y tree. pub(crate) const ROOT_NODE_ID: NodeId = NodeId(0); @@ -132,380 +135,6 @@ impl A11y { } } -pub(crate) struct A11yNodeBuilder { - ids_stack: SmallVec<[NodeId; 16]>, - nodes_stack: SmallVec<[accesskit::Node; 16]>, - suppression_stack: SmallVec<[bool; 16]>, - ambient_suppression_depth: usize, - all_nodes: Vec<(NodeId, accesskit::Node)>, - seen_ids: FxHashSet, - focus: NodeId, - #[cfg(debug_assertions)] - has_set_focus: bool, -} - -pub(crate) struct A11yPrepaintSnapshot { - nodes: A11yNodeBuilderPrepaintSnapshot, - node_ids: FxHashMap, - visited_global_ids: FxHashSet, - next_node_id: u64, - focus_ids: FxHashMap, - node_bounds: FxHashMap>, -} - -struct A11yNodeBuilderPrepaintSnapshot { - ids_stack: SmallVec<[NodeId; 16]>, - nodes_stack: SmallVec<[accesskit::Node; 16]>, - suppression_stack: SmallVec<[bool; 16]>, - ambient_suppression_depth: usize, - all_nodes: Vec<(NodeId, accesskit::Node)>, - seen_ids: FxHashSet, - focus: NodeId, - #[cfg(debug_assertions)] - has_set_focus: bool, -} - -impl A11yNodeBuilder { - fn new() -> Self { - Self { - ids_stack: SmallVec::new(), - nodes_stack: SmallVec::new(), - suppression_stack: SmallVec::new(), - ambient_suppression_depth: 0, - all_nodes: Vec::new(), - seen_ids: FxHashSet::default(), - focus: ROOT_NODE_ID, - #[cfg(debug_assertions)] - has_set_focus: false, - } - } - - pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool { - debug_assert!(!self.ids_stack.is_empty(), "push called before begin_frame"); - - if self.is_suppressed() { - return false; - } - - if !self.seen_ids.insert(id) { - debug_assert!( - false, - "duplicate a11y node id: {id:?}; release builds discard this node" - ); - return false; - } - - self.ids_stack.push(id); - self.nodes_stack.push(node); - self.suppression_stack.push(false); - true - } - - pub(crate) fn pop(&mut self) { - debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node"); - - self.pop_any(); - } - - fn begin_frame(&mut self) { - self.all_nodes.clear(); - self.ids_stack.clear(); - self.nodes_stack.clear(); - self.suppression_stack.clear(); - self.ambient_suppression_depth = 0; - self.seen_ids.clear(); - self.seen_ids.insert(ROOT_NODE_ID); - #[cfg(debug_assertions)] - { - self.has_set_focus = false; - } - - self.ids_stack.push(ROOT_NODE_ID); - self.nodes_stack - .push(accesskit::Node::new(accesskit::Role::Window)); - self.suppression_stack.push(false); - self.focus = ROOT_NODE_ID; - } - - #[cfg(test)] - fn has_node(&self, id: NodeId) -> bool { - id == ROOT_NODE_ID || self.seen_ids.contains(&id) - } - - pub(crate) fn has_current_node(&self, id: NodeId) -> bool { - self.ids_stack.last().copied() == Some(id) && !self.is_suppressed() - } - - pub(crate) fn set_focus(&mut self, id: NodeId) { - #[cfg(debug_assertions)] - { - debug_assert!( - !self.has_set_focus, - "set_focus called more than once in a single frame" - ); - self.has_set_focus = true; - } - self.focus = id; - } - - fn prepaint_snapshot(&self) -> A11yNodeBuilderPrepaintSnapshot { - A11yNodeBuilderPrepaintSnapshot { - ids_stack: self.ids_stack.clone(), - nodes_stack: self.nodes_stack.clone(), - suppression_stack: self.suppression_stack.clone(), - ambient_suppression_depth: self.ambient_suppression_depth, - all_nodes: self.all_nodes.clone(), - seen_ids: self.seen_ids.clone(), - focus: self.focus, - #[cfg(debug_assertions)] - has_set_focus: self.has_set_focus, - } - } - - fn restore_prepaint_snapshot(&mut self, snapshot: A11yNodeBuilderPrepaintSnapshot) { - self.ids_stack = snapshot.ids_stack; - self.nodes_stack = snapshot.nodes_stack; - self.suppression_stack = snapshot.suppression_stack; - self.ambient_suppression_depth = snapshot.ambient_suppression_depth; - self.all_nodes = snapshot.all_nodes; - self.seen_ids = snapshot.seen_ids; - self.focus = snapshot.focus; - #[cfg(debug_assertions)] - { - self.has_set_focus = snapshot.has_set_focus; - } - } - - #[allow(dead_code)] - pub(crate) fn update_current_node_bounds( - &mut self, - id: NodeId, - bounds: Bounds, - scale_factor: f32, - ) -> bool { - let Some(node) = self.current_node_mut(id) else { - return false; - }; - - let scale = scale_factor; - node.set_bounds(accesskit::Rect { - x0: (bounds.origin.x.0 * scale) as f64, - y0: (bounds.origin.y.0 * scale) as f64, - x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64, - y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, - }); - true - } - - #[allow(dead_code)] - pub(crate) fn suppress_current_node(&mut self, id: NodeId) -> bool { - if self.ids_stack.len() <= 1 { - debug_assert!(false, "cannot suppress the root a11y node"); - return false; - } - - if self.ids_stack.last().copied() != Some(id) { - return false; - } - - let Some(suppressed) = self.suppression_stack.last_mut() else { - return false; - }; - - if *suppressed { - return false; - } - - *suppressed = true; - self.prune_emitted_subtree(id); - true - } - - #[allow(dead_code)] - pub(crate) fn begin_suppressing_descendants(&mut self) { - self.ambient_suppression_depth += 1; - } - - #[allow(dead_code)] - pub(crate) fn end_suppressing_descendants(&mut self) { - debug_assert!( - self.ambient_suppression_depth > 0, - "end_suppressing_descendants called without matching begin" - ); - self.ambient_suppression_depth = self.ambient_suppression_depth.saturating_sub(1); - } - - fn finalize(&mut self) -> TreeUpdate { - debug_assert_eq!(self.ids_stack.len(), 1); - debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID); - debug_assert_eq!(self.ambient_suppression_depth, 0); - - if self.ids_stack.len() != 1 { - log::error!( - "a11y: stack imbalance at end of frame: expected 1 (root), got {}", - self.ids_stack.len() - ); - } - if self.ambient_suppression_depth != 0 { - log::error!( - "a11y: ambient suppression imbalance at end of frame: got {}", - self.ambient_suppression_depth - ); - self.ambient_suppression_depth = 0; - } - - while !self.ids_stack.is_empty() { - self.pop_any(); - } - - let update = TreeUpdate { - nodes: std::mem::take(&mut self.all_nodes), - tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), - tree_id: accesskit::TreeId::ROOT, - focus: self.focus, - }; - - Self::repair_tree_update(update) - } - - #[allow(dead_code)] - fn current_node_mut(&mut self, id: NodeId) -> Option<&mut accesskit::Node> { - if self.ids_stack.len() <= 1 - || self.ids_stack.last().copied() != Some(id) - || self.suppression_stack.last().copied().unwrap_or(true) - { - None - } else { - self.nodes_stack.last_mut() - } - } - - fn is_suppressed(&self) -> bool { - self.ambient_suppression_depth > 0 - || self.suppression_stack.last().copied().unwrap_or_default() - } - - fn prune_emitted_subtree(&mut self, id: NodeId) { - let mut pruned_ids = FxHashSet::default(); - pruned_ids.insert(id); - - if let Some(current_node) = self.nodes_stack.last() { - let mut pending = current_node.children().to_vec(); - if !pending.is_empty() { - let emitted_nodes_by_id = self - .all_nodes - .iter() - .map(|(node_id, node)| (*node_id, node)) - .collect::>(); - while let Some(child_id) = pending.pop() { - if !pruned_ids.insert(child_id) { - continue; - } - - if let Some(child_node) = emitted_nodes_by_id.get(&child_id) { - pending.extend(child_node.children().iter().copied()); - } - } - } - } - - for node_id in &pruned_ids { - self.seen_ids.remove(node_id); - } - - if pruned_ids.contains(&self.focus) { - self.focus = ROOT_NODE_ID; - } - - self.all_nodes - .retain(|(node_id, _)| !pruned_ids.contains(node_id)); - - for (_, node) in &mut self.all_nodes { - Self::remove_child_refs(node, &pruned_ids); - } - for node in &mut self.nodes_stack { - Self::remove_child_refs(node, &pruned_ids); - } - } - - fn remove_child_refs(node: &mut accesskit::Node, removed_ids: &FxHashSet) { - if node - .children() - .iter() - .any(|child_id| removed_ids.contains(child_id)) - { - let children = node - .children() - .iter() - .copied() - .filter(|child_id| !removed_ids.contains(child_id)) - .collect::>(); - node.set_children(children); - } - } - - fn pop_any(&mut self) { - if let (Some(id), Some(node), Some(suppressed)) = ( - self.ids_stack.pop(), - self.nodes_stack.pop(), - self.suppression_stack.pop(), - ) { - if suppressed { - return; - } - - if let (Some(parent), Some(parent_suppressed)) = - (self.nodes_stack.last_mut(), self.suppression_stack.last()) - { - if !*parent_suppressed { - parent.push_child(id); - } - } - self.all_nodes.push((id, node)); - } - } - - fn repair_tree_update(mut update: TreeUpdate) -> TreeUpdate { - let node_ids: FxHashSet = update.nodes.iter().map(|(id, _)| *id).collect(); - - if !node_ids.contains(&update.focus) { - log::error!( - "a11y: focused node {:?} is not in the tree ({} nodes); falling back to root", - update.focus, - update.nodes.len() - ); - update.focus = ROOT_NODE_ID; - } - - for (id, node) in &mut update.nodes { - let has_invalid_child = node - .children() - .iter() - .any(|child_id| !node_ids.contains(child_id)); - if has_invalid_child { - let children = node.children(); - let invalid_count = children - .iter() - .filter(|child_id| !node_ids.contains(child_id)) - .count(); - log::error!( - "a11y: node {:?} references {} children not present in the tree; stripping invalid child references", - id, - invalid_count - ); - let valid = children - .iter() - .copied() - .filter(|child_id| node_ids.contains(child_id)) - .collect::>(); - node.set_children(valid); - } - } - - update - } -} - #[cfg(test)] mod tests { use super::*; @@ -517,236 +146,10 @@ mod tests { GlobalElementId(Arc::from([id.into()])) } - fn node<'a>(update: &'a TreeUpdate, id: NodeId) -> &'a accesskit::Node { - update - .nodes - .iter() - .find_map(|(node_id, node)| (*node_id == id).then_some(node)) - .unwrap() - } - fn has_update_node(update: &TreeUpdate, id: NodeId) -> bool { update.nodes.iter().any(|(node_id, _)| *node_id == id) } - #[test] - fn preserves_unsuppressed_tree_shape() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); - builder.pop(); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); - assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); - assert_eq!(node(&update, NodeId(2)).children(), &[]); - } - - #[test] - fn updates_current_non_root_node_bounds() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - assert!(builder.update_current_node_bounds( - NodeId(1), - Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), - 2., - )); - builder.pop(); - - let update = builder.finalize(); - assert_eq!( - node(&update, NodeId(1)).bounds(), - Some(accesskit::Rect { - x0: 4., - y0: 6., - x1: 12., - y1: 16., - }) - ); - } - - #[test] - fn suppresses_current_node_before_finalization() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - assert!(builder.suppress_current_node(NodeId(1))); - assert!(!builder.has_node(NodeId(1))); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(update.nodes.len(), 1); - assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); - } - - #[test] - fn skips_descendants_while_current_node_is_suppressed() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - assert!(builder.suppress_current_node(NodeId(1))); - assert!(!builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); - builder.pop(); - - assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(update.nodes.len(), 2); - assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(2)]); - assert_eq!(node(&update, NodeId(2)).children(), &[]); - } - - #[test] - fn ambient_descendant_suppression_under_root_skips_child_push_without_suppressing_root() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - builder.begin_suppressing_descendants(); - assert!(!builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - builder.end_suppressing_descendants(); - - assert!(builder.has_node(ROOT_NODE_ID)); - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(update.nodes.len(), 2); - assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); - } - - #[test] - fn id_specific_update_requires_current_node() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - assert!(!builder.update_current_node_bounds( - NodeId(2), - Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), - 2., - )); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(node(&update, NodeId(1)).bounds(), None); - } - - #[test] - fn id_specific_suppress_requires_current_node() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - assert!(!builder.suppress_current_node(NodeId(2))); - assert!(builder.has_node(NodeId(1))); - assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); - builder.pop(); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(update.nodes.len(), 3); - assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); - assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); - } - - #[test] - fn suppressing_focused_current_node_removes_focus_from_tree() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - builder.set_focus(NodeId(1)); - assert!(builder.suppress_current_node(NodeId(1))); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(update.focus, ROOT_NODE_ID); - assert_eq!(update.nodes.len(), 1); - } - - #[test] - fn suppressing_current_node_prunes_already_emitted_descendants() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); - assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::TextInput))); - builder.set_focus(NodeId(3)); - builder.pop(); - builder.pop(); - - assert!(builder.suppress_current_node(NodeId(1))); - assert!(!builder.has_node(NodeId(1))); - assert!(!builder.has_node(NodeId(2))); - assert!(!builder.has_node(NodeId(3))); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(update.nodes.len(), 1); - assert_eq!(update.focus, ROOT_NODE_ID); - assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); - assert!(!has_update_node(&update, NodeId(1))); - assert!(!has_update_node(&update, NodeId(2))); - assert!(!has_update_node(&update, NodeId(3))); - } - - #[test] - fn parent_children_do_not_reference_suppressed_descendants() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); - assert!(builder.suppress_current_node(NodeId(2))); - builder.pop(); - - assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); - builder.pop(); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(update.nodes.len(), 3); - assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); - assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(3)]); - } - - #[test] - fn restores_prepaint_snapshot_for_builder_state() { - let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); - - assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); - let snapshot = builder.prepaint_snapshot(); - - assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); - builder.pop(); - builder.begin_suppressing_descendants(); - assert!(!builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); - builder.set_focus(NodeId(1)); - - builder.restore_prepaint_snapshot(snapshot); - - assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); - builder.set_focus(NodeId(2)); - builder.pop(); - builder.pop(); - - let update = builder.finalize(); - assert_eq!(update.nodes.len(), 3); - assert_eq!(update.focus, NodeId(2)); - assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); - assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); - } - #[test] fn restores_prepaint_snapshot_for_a11y_maps() { let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); diff --git a/crates/gpui/src/window/a11y/builder.rs b/crates/gpui/src/window/a11y/builder.rs new file mode 100644 index 0000000..56e21d5 --- /dev/null +++ b/crates/gpui/src/window/a11y/builder.rs @@ -0,0 +1,383 @@ +use super::ROOT_NODE_ID; +use crate::{Bounds, FocusId, GlobalElementId, Pixels}; +use accesskit::{NodeId, TreeUpdate}; +use collections::{FxHashMap, FxHashSet}; +use smallvec::SmallVec; + +pub(crate) struct A11yNodeBuilder { + ids_stack: SmallVec<[NodeId; 16]>, + nodes_stack: SmallVec<[accesskit::Node; 16]>, + suppression_stack: SmallVec<[bool; 16]>, + ambient_suppression_depth: usize, + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + focus: NodeId, + #[cfg(debug_assertions)] + has_set_focus: bool, +} + +pub(crate) struct A11yPrepaintSnapshot { + pub(super) nodes: A11yNodeBuilderPrepaintSnapshot, + pub(super) node_ids: FxHashMap, + pub(super) visited_global_ids: FxHashSet, + pub(super) next_node_id: u64, + pub(super) focus_ids: FxHashMap, + pub(super) node_bounds: FxHashMap>, +} + +pub(super) struct A11yNodeBuilderPrepaintSnapshot { + ids_stack: SmallVec<[NodeId; 16]>, + nodes_stack: SmallVec<[accesskit::Node; 16]>, + suppression_stack: SmallVec<[bool; 16]>, + ambient_suppression_depth: usize, + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + focus: NodeId, + #[cfg(debug_assertions)] + has_set_focus: bool, +} + +impl A11yNodeBuilder { + pub(super) fn new() -> Self { + Self { + ids_stack: SmallVec::new(), + nodes_stack: SmallVec::new(), + suppression_stack: SmallVec::new(), + ambient_suppression_depth: 0, + all_nodes: Vec::new(), + seen_ids: FxHashSet::default(), + focus: ROOT_NODE_ID, + #[cfg(debug_assertions)] + has_set_focus: false, + } + } + + pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool { + debug_assert!(!self.ids_stack.is_empty(), "push called before begin_frame"); + + if self.is_suppressed() { + return false; + } + + if !self.seen_ids.insert(id) { + debug_assert!( + false, + "duplicate a11y node id: {id:?}; release builds discard this node" + ); + return false; + } + + self.ids_stack.push(id); + self.nodes_stack.push(node); + self.suppression_stack.push(false); + true + } + + pub(crate) fn pop(&mut self) { + debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node"); + + self.pop_any(); + } + + pub(super) fn begin_frame(&mut self) { + self.all_nodes.clear(); + self.ids_stack.clear(); + self.nodes_stack.clear(); + self.suppression_stack.clear(); + self.ambient_suppression_depth = 0; + self.seen_ids.clear(); + self.seen_ids.insert(ROOT_NODE_ID); + #[cfg(debug_assertions)] + { + self.has_set_focus = false; + } + + self.ids_stack.push(ROOT_NODE_ID); + self.nodes_stack + .push(accesskit::Node::new(accesskit::Role::Window)); + self.suppression_stack.push(false); + self.focus = ROOT_NODE_ID; + } + + #[cfg(test)] + fn has_node(&self, id: NodeId) -> bool { + id == ROOT_NODE_ID || self.seen_ids.contains(&id) + } + + pub(crate) fn has_current_node(&self, id: NodeId) -> bool { + self.ids_stack.last().copied() == Some(id) && !self.is_suppressed() + } + + pub(crate) fn set_focus(&mut self, id: NodeId) { + #[cfg(debug_assertions)] + { + debug_assert!( + !self.has_set_focus, + "set_focus called more than once in a single frame" + ); + self.has_set_focus = true; + } + self.focus = id; + } + + pub(super) fn prepaint_snapshot(&self) -> A11yNodeBuilderPrepaintSnapshot { + A11yNodeBuilderPrepaintSnapshot { + ids_stack: self.ids_stack.clone(), + nodes_stack: self.nodes_stack.clone(), + suppression_stack: self.suppression_stack.clone(), + ambient_suppression_depth: self.ambient_suppression_depth, + all_nodes: self.all_nodes.clone(), + seen_ids: self.seen_ids.clone(), + focus: self.focus, + #[cfg(debug_assertions)] + has_set_focus: self.has_set_focus, + } + } + + pub(super) fn restore_prepaint_snapshot(&mut self, snapshot: A11yNodeBuilderPrepaintSnapshot) { + self.ids_stack = snapshot.ids_stack; + self.nodes_stack = snapshot.nodes_stack; + self.suppression_stack = snapshot.suppression_stack; + self.ambient_suppression_depth = snapshot.ambient_suppression_depth; + self.all_nodes = snapshot.all_nodes; + self.seen_ids = snapshot.seen_ids; + self.focus = snapshot.focus; + #[cfg(debug_assertions)] + { + self.has_set_focus = snapshot.has_set_focus; + } + } + + #[allow(dead_code)] + pub(crate) fn update_current_node_bounds( + &mut self, + id: NodeId, + bounds: Bounds, + scale_factor: f32, + ) -> bool { + let Some(node) = self.current_node_mut(id) else { + return false; + }; + + let scale = scale_factor; + node.set_bounds(accesskit::Rect { + x0: (bounds.origin.x.0 * scale) as f64, + y0: (bounds.origin.y.0 * scale) as f64, + x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64, + y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, + }); + true + } + + #[allow(dead_code)] + pub(crate) fn suppress_current_node(&mut self, id: NodeId) -> bool { + if self.ids_stack.len() <= 1 { + debug_assert!(false, "cannot suppress the root a11y node"); + return false; + } + + if self.ids_stack.last().copied() != Some(id) { + return false; + } + + let Some(suppressed) = self.suppression_stack.last_mut() else { + return false; + }; + + if *suppressed { + return false; + } + + *suppressed = true; + self.prune_emitted_subtree(id); + true + } + + #[allow(dead_code)] + pub(crate) fn begin_suppressing_descendants(&mut self) { + self.ambient_suppression_depth += 1; + } + + #[allow(dead_code)] + pub(crate) fn end_suppressing_descendants(&mut self) { + debug_assert!( + self.ambient_suppression_depth > 0, + "end_suppressing_descendants called without matching begin" + ); + self.ambient_suppression_depth = self.ambient_suppression_depth.saturating_sub(1); + } + + pub(super) fn finalize(&mut self) -> TreeUpdate { + debug_assert_eq!(self.ids_stack.len(), 1); + debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID); + debug_assert_eq!(self.ambient_suppression_depth, 0); + + if self.ids_stack.len() != 1 { + log::error!( + "a11y: stack imbalance at end of frame: expected 1 (root), got {}", + self.ids_stack.len() + ); + } + if self.ambient_suppression_depth != 0 { + log::error!( + "a11y: ambient suppression imbalance at end of frame: got {}", + self.ambient_suppression_depth + ); + self.ambient_suppression_depth = 0; + } + + while !self.ids_stack.is_empty() { + self.pop_any(); + } + + let update = TreeUpdate { + nodes: std::mem::take(&mut self.all_nodes), + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: self.focus, + }; + + Self::repair_tree_update(update) + } + + #[allow(dead_code)] + fn current_node_mut(&mut self, id: NodeId) -> Option<&mut accesskit::Node> { + if self.ids_stack.len() <= 1 + || self.ids_stack.last().copied() != Some(id) + || self.suppression_stack.last().copied().unwrap_or(true) + { + None + } else { + self.nodes_stack.last_mut() + } + } + + fn is_suppressed(&self) -> bool { + self.ambient_suppression_depth > 0 + || self.suppression_stack.last().copied().unwrap_or_default() + } + + fn prune_emitted_subtree(&mut self, id: NodeId) { + let mut pruned_ids = FxHashSet::default(); + pruned_ids.insert(id); + + if let Some(current_node) = self.nodes_stack.last() { + let mut pending = current_node.children().to_vec(); + if !pending.is_empty() { + let emitted_nodes_by_id = self + .all_nodes + .iter() + .map(|(node_id, node)| (*node_id, node)) + .collect::>(); + while let Some(child_id) = pending.pop() { + if !pruned_ids.insert(child_id) { + continue; + } + + if let Some(child_node) = emitted_nodes_by_id.get(&child_id) { + pending.extend(child_node.children().iter().copied()); + } + } + } + } + + for node_id in &pruned_ids { + self.seen_ids.remove(node_id); + } + + if pruned_ids.contains(&self.focus) { + self.focus = ROOT_NODE_ID; + } + + self.all_nodes + .retain(|(node_id, _)| !pruned_ids.contains(node_id)); + + for (_, node) in &mut self.all_nodes { + Self::remove_child_refs(node, &pruned_ids); + } + for node in &mut self.nodes_stack { + Self::remove_child_refs(node, &pruned_ids); + } + } + + fn remove_child_refs(node: &mut accesskit::Node, removed_ids: &FxHashSet) { + if node + .children() + .iter() + .any(|child_id| removed_ids.contains(child_id)) + { + let children = node + .children() + .iter() + .copied() + .filter(|child_id| !removed_ids.contains(child_id)) + .collect::>(); + node.set_children(children); + } + } + + fn pop_any(&mut self) { + if let (Some(id), Some(node), Some(suppressed)) = ( + self.ids_stack.pop(), + self.nodes_stack.pop(), + self.suppression_stack.pop(), + ) { + if suppressed { + return; + } + + if let (Some(parent), Some(parent_suppressed)) = + (self.nodes_stack.last_mut(), self.suppression_stack.last()) + { + if !*parent_suppressed { + parent.push_child(id); + } + } + self.all_nodes.push((id, node)); + } + } + + fn repair_tree_update(mut update: TreeUpdate) -> TreeUpdate { + let node_ids: FxHashSet = update.nodes.iter().map(|(id, _)| *id).collect(); + + if !node_ids.contains(&update.focus) { + log::error!( + "a11y: focused node {:?} is not in the tree ({} nodes); falling back to root", + update.focus, + update.nodes.len() + ); + update.focus = ROOT_NODE_ID; + } + + for (id, node) in &mut update.nodes { + let has_invalid_child = node + .children() + .iter() + .any(|child_id| !node_ids.contains(child_id)); + if has_invalid_child { + let children = node.children(); + let invalid_count = children + .iter() + .filter(|child_id| !node_ids.contains(child_id)) + .count(); + log::error!( + "a11y: node {:?} references {} children not present in the tree; stripping invalid child references", + id, + invalid_count + ); + let valid = children + .iter() + .copied() + .filter(|child_id| node_ids.contains(child_id)) + .collect::>(); + node.set_children(valid); + } + } + + update + } +} + +#[cfg(test)] +#[path = "builder_tests.rs"] +mod tests; diff --git a/crates/gpui/src/window/a11y/builder_tests.rs b/crates/gpui/src/window/a11y/builder_tests.rs new file mode 100644 index 0000000..476b3e5 --- /dev/null +++ b/crates/gpui/src/window/a11y/builder_tests.rs @@ -0,0 +1,232 @@ +use super::*; +use crate::{point, px, size}; + +fn node<'a>(update: &'a TreeUpdate, id: NodeId) -> &'a accesskit::Node { + update + .nodes + .iter() + .find_map(|(node_id, node)| (*node_id == id).then_some(node)) + .unwrap() +} + +fn has_update_node(update: &TreeUpdate, id: NodeId) -> bool { + update.nodes.iter().any(|(node_id, _)| *node_id == id) +} + +#[test] +fn preserves_unsuppressed_tree_shape() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); + assert_eq!(node(&update, NodeId(2)).children(), &[]); +} + +#[test] +fn updates_current_non_root_node_bounds() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.update_current_node_bounds( + NodeId(1), + Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), + 2., + )); + builder.pop(); + + let update = builder.finalize(); + assert_eq!( + node(&update, NodeId(1)).bounds(), + Some(accesskit::Rect { + x0: 4., + y0: 6., + x1: 12., + y1: 16., + }) + ); +} + +#[test] +fn suppresses_current_node_before_finalization() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.has_node(NodeId(1))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 1); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); +} + +#[test] +fn skips_descendants_while_current_node_is_suppressed() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 2); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(2)]); + assert_eq!(node(&update, NodeId(2)).children(), &[]); +} + +#[test] +fn ambient_descendant_suppression_under_root_skips_child_push_without_suppressing_root() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + builder.begin_suppressing_descendants(); + assert!(!builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.end_suppressing_descendants(); + + assert!(builder.has_node(ROOT_NODE_ID)); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 2); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); +} + +#[test] +fn id_specific_update_requires_current_node() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(!builder.update_current_node_bounds( + NodeId(2), + Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), + 2., + )); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(node(&update, NodeId(1)).bounds(), None); +} + +#[test] +fn id_specific_suppress_requires_current_node() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(!builder.suppress_current_node(NodeId(2))); + assert!(builder.has_node(NodeId(1))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); +} + +#[test] +fn suppressing_focused_current_node_removes_focus_from_tree() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.set_focus(NodeId(1)); + assert!(builder.suppress_current_node(NodeId(1))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.focus, ROOT_NODE_ID); + assert_eq!(update.nodes.len(), 1); +} + +#[test] +fn suppressing_current_node_prunes_already_emitted_descendants() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::TextInput))); + builder.set_focus(NodeId(3)); + builder.pop(); + builder.pop(); + + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.has_node(NodeId(1))); + assert!(!builder.has_node(NodeId(2))); + assert!(!builder.has_node(NodeId(3))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 1); + assert_eq!(update.focus, ROOT_NODE_ID); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); + assert!(!has_update_node(&update, NodeId(1))); + assert!(!has_update_node(&update, NodeId(2))); + assert!(!has_update_node(&update, NodeId(3))); +} + +#[test] +fn parent_children_do_not_reference_suppressed_descendants() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + assert!(builder.suppress_current_node(NodeId(2))); + builder.pop(); + + assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(3)]); +} + +#[test] +fn restores_prepaint_snapshot_for_builder_state() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + let snapshot = builder.prepaint_snapshot(); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.begin_suppressing_descendants(); + assert!(!builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); + builder.set_focus(NodeId(1)); + + builder.restore_prepaint_snapshot(snapshot); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.set_focus(NodeId(2)); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(update.focus, NodeId(2)); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); +} diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index d0bc4f8..aeba611 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -880,7 +880,8 @@ impl LinuxClient for WaylandClient { } fn is_cursor_visible(&self) -> bool { - self.0.borrow().cursor_hidden_window.is_none() + let state = self.0.borrow(); + state.cursor_hidden_window.is_none() && state.cursor_style != Some(CursorStyle::None) } fn open_uri(&self, uri: &str) { diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index df0583e..034e4f7 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -1065,7 +1065,8 @@ impl Platform for MacPlatform { } fn is_cursor_visible(&self) -> bool { - self.0.lock().cursor_visible.load(Ordering::Relaxed) + let state = self.0.lock(); + !state.cursor_hidden_by_style && state.cursor_visible.load(Ordering::Relaxed) } fn should_auto_hide_scrollbars(&self) -> bool { diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 5c8c10b..a81a359 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -13,7 +13,7 @@ use futures::channel::oneshot; use std::{ any::Any, future::Future, - panic::Location, + panic::{self, AssertUnwindSafe, Location}, pin::Pin, sync::Arc, task::{Context, Poll}, @@ -137,7 +137,8 @@ where Fut::Output: Send + 'static, { let (runnable_sender, runnable_receiver) = flume::unbounded::>(); - let (task_sender, task_receiver) = flume::bounded::>(1); + let (task_sender, task_receiver) = + flume::bounded::, Box>>(1); thread::Builder::new() .name(format!("spawn_dedicated session {:?}", session_id)) @@ -146,8 +147,21 @@ where let _ = runnable_sender.send(runnable); }; let executor = LocalExecutor::new(session_id, scheduler, dispatch); - let root_task = executor.spawn(f(executor.clone())); - let _ = task_sender.send(root_task); + let root_task = + panic::catch_unwind(AssertUnwindSafe(|| executor.spawn(f(executor.clone())))); + let root_task = match root_task { + Ok(root_task) => root_task, + Err(error) => { + if task_sender.send(Err(error)).is_err() { + panic!("dedicated thread failed to send root task panic"); + } + return; + } + }; + + if task_sender.send(Ok(root_task)).is_err() { + panic!("dedicated thread failed to send root task"); + } // Close the dispatch sender so the dedicated thread exits after draining queued work. drop(executor); @@ -157,9 +171,11 @@ where }) .expect("failed to spawn dedicated thread"); - task_receiver - .recv() - .expect("dedicated thread failed to produce root task") + match task_receiver.recv() { + Ok(Ok(root_task)) => root_task, + Ok(Err(error)) => panic::resume_unwind(error), + Err(error) => panic!("dedicated thread failed to produce root task: {}", error), + } } #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index eb3466b..fa852ac 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -882,6 +882,35 @@ fn test_spawn_dedicated_inner_spawn_local() { assert_eq!(result, 99); } +#[test] +fn test_spawn_dedicated_thread_closure_panic_reaches_caller() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = spawn_dedicated_thread( + SessionId::new(1), + scheduler, + |_executor| -> future::Ready<()> { + panic!("dedicated root task constructor exploded"); + }, + ); + })); + + assert!(result.is_err(), "expected spawn_dedicated_thread to panic"); + let panic_payload = result.unwrap_err(); + let panic_message = panic_payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic_payload.downcast_ref::<&str>().copied()) + .unwrap_or(""); + + assert!( + panic_message.contains("dedicated root task constructor exploded"), + "expected caller panic to include the dedicated closure panic, got: {}", + panic_message + ); +} + #[test] fn test_spawn_dedicated_determinism_under_many() { use parking_lot::Mutex; From 1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 2 Jun 2026 14:44:46 -0700 Subject: [PATCH 21/22] fix: resolve upstream sync review findings --- crates/gpui/examples/a11y.rs | 2 +- crates/gpui/src/elements/list.rs | 27 +++++++++++++++++- crates/gpui/src/text_system/line_wrapper.rs | 20 +++++++++++++ crates/gpui/src/window/a11y/builder.rs | 14 +++------- crates/gpui_linux/src/linux/platform.rs | 6 ++-- crates/gpui_linux/src/linux/wayland/window.rs | 28 ++++++++++++++++++- crates/gpui_linux/src/linux/x11/client.rs | 25 +++++++++++++---- crates/gpui_linux/src/linux/x11/window.rs | 11 ++++++-- crates/gpui_web/src/platform.rs | 2 +- crates/gpui_wgpu/src/cosmic_text_system.rs | 13 ++++----- crates/gpui_windows/src/window.rs | 12 ++++---- 11 files changed, 121 insertions(+), 39 deletions(-) diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs index 2418aaa..0ef6116 100644 --- a/crates/gpui/examples/a11y.rs +++ b/crates/gpui/examples/a11y.rs @@ -68,7 +68,7 @@ impl Render for A11yDemo { .focusable() .tab_stop(true) .role(Role::SpinButton) - .aria_label(SharedString::from(format!("Counter: {}", self.count))) + .aria_label("Counter") .aria_numeric_value(self.count as f64) .aria_min_numeric_value(0.0) .on_a11y_action(AccessibleAction::Increment, { diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 8f8208a..586ad30 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -660,8 +660,11 @@ impl ListState { /// been rendered. pub fn bounds_for_item(&self, ix: usize) -> Option> { let state = &*self.0.borrow(); + let scroll_top = state + .last_layout_scroll_top + .unwrap_or_else(|| state.logical_scroll_top()); - state.bounds_for_item_at_scroll_top(ix, state.logical_scroll_top()) + state.bounds_for_item_at_scroll_top(ix, scroll_top) } /// Call this method when the user starts dragging the scrollbar. @@ -1739,6 +1742,28 @@ mod test { assert_eq!(state.item_is_below_viewport(1), Some(false)); } + #[gpui::test] + fn test_bounds_for_item_uses_last_layout_scroll_top(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(0.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + state.0.borrow_mut().logical_scroll_top = Some(gpui::ListOffset { + item_ix: 0, + offset_in_item: px(0.), + }); + + assert_eq!(state.bounds_for_item(2).unwrap().top(), px(0.)); + } + #[gpui::test] fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index b0068b2..a0c9c2d 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -681,6 +681,7 @@ fn update_runs_after_truncation( } else { run.len = truncate_at + ellipsis.len(); runs.splice(..run_index, std::iter::empty()); + runs.retain(|run| run.len > 0); return; } } @@ -698,6 +699,7 @@ fn update_runs_after_truncation( } else { run.len = truncate_at + ellipsis.len(); runs.truncate(run_index + 1); + runs.retain(|run| run.len > 0); return; } } @@ -707,6 +709,7 @@ fn update_runs_after_truncation( } } } + runs.retain(|run| run.len > 0); } /// A fragment of a line that can be wrapped. @@ -1274,6 +1277,23 @@ mod tests { perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]); } + #[test] + fn test_update_runs_after_truncation_drops_empty_boundary_runs() { + let mut end_runs = generate_test_runs(&[4, 4, 4]); + update_runs_after_truncation("abcdefgh", "", &mut end_runs, TruncateFrom::End); + assert_eq!( + end_runs.iter().map(|run| run.len).collect::>(), + vec![4, 4] + ); + + let mut start_runs = generate_test_runs(&[4, 4, 4]); + update_runs_after_truncation("efghijkl", "", &mut start_runs, TruncateFrom::Start); + assert_eq!( + start_runs.iter().map(|run| run.len).collect::>(), + vec![4, 4] + ); + } + #[test] fn test_multiline_truncation_fits_within_wrapped_lines() { let mut wrapper = build_wrapper(); diff --git a/crates/gpui/src/window/a11y/builder.rs b/crates/gpui/src/window/a11y/builder.rs index 56e21d5..a92038f 100644 --- a/crates/gpui/src/window/a11y/builder.rs +++ b/crates/gpui/src/window/a11y/builder.rs @@ -148,7 +148,6 @@ impl A11yNodeBuilder { } } - #[allow(dead_code)] pub(crate) fn update_current_node_bounds( &mut self, id: NodeId, @@ -159,17 +158,15 @@ impl A11yNodeBuilder { return false; }; - let scale = scale_factor; node.set_bounds(accesskit::Rect { - x0: (bounds.origin.x.0 * scale) as f64, - y0: (bounds.origin.y.0 * scale) as f64, - x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64, - y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, + x0: (bounds.origin.x.0 * scale_factor) as f64, + y0: (bounds.origin.y.0 * scale_factor) as f64, + x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale_factor) as f64, + y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale_factor) as f64, }); true } - #[allow(dead_code)] pub(crate) fn suppress_current_node(&mut self, id: NodeId) -> bool { if self.ids_stack.len() <= 1 { debug_assert!(false, "cannot suppress the root a11y node"); @@ -193,12 +190,10 @@ impl A11yNodeBuilder { true } - #[allow(dead_code)] pub(crate) fn begin_suppressing_descendants(&mut self) { self.ambient_suppression_depth += 1; } - #[allow(dead_code)] pub(crate) fn end_suppressing_descendants(&mut self) { debug_assert!( self.ambient_suppression_depth > 0, @@ -240,7 +235,6 @@ impl A11yNodeBuilder { Self::repair_tree_update(update) } - #[allow(dead_code)] fn current_node_mut(&mut self, id: NodeId) -> Option<&mut accesskit::Node> { if self.ids_stack.len() <= 1 || self.ids_stack.last().copied() != Some(id) diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index c70a2ae..eda27f7 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -123,10 +123,8 @@ pub(crate) trait LinuxClient { options: WindowParams, ) -> anyhow::Result>; fn set_cursor_style(&self, style: CursorStyle); - fn hide_cursor_until_mouse_moves(&self) {} - fn is_cursor_visible(&self) -> bool { - true - } + fn hide_cursor_until_mouse_moves(&self); + fn is_cursor_visible(&self) -> bool; fn open_uri(&self, uri: &str); fn reveal_path(&self, path: PathBuf); fn write_to_primary(&self, item: ClipboardItem); diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 9654868..5c45d3d 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -441,6 +441,30 @@ impl WaylandWindowState { WindowDecorations::Client => self.client_inset.unwrap_or(px(0.0)), } } + + fn update_accesskit_window_bounds(&mut self) { + let scale = self.scale; + let bounds = self.bounds.map_origin(|_| px(0.0)); + let inner_bounds = bounds.inset(self.inset()); + + let outer = accesskit::Rect { + x0: f64::from(f32::from(bounds.origin.x) * scale), + y0: f64::from(f32::from(bounds.origin.y) * scale), + x1: f64::from(f32::from(bounds.origin.x + bounds.size.width) * scale), + y1: f64::from(f32::from(bounds.origin.y + bounds.size.height) * scale), + }; + + let inner = accesskit::Rect { + x0: f64::from(f32::from(inner_bounds.origin.x) * scale), + y0: f64::from(f32::from(inner_bounds.origin.y) * scale), + x1: f64::from(f32::from(inner_bounds.origin.x + inner_bounds.size.width) * scale), + y1: f64::from(f32::from(inner_bounds.origin.y + inner_bounds.size.height) * scale), + }; + + if let Some(adapter) = self.accesskit_adapter.as_mut() { + adapter.set_root_window_bounds(outer, inner); + } + } } pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr); @@ -958,6 +982,7 @@ impl WaylandWindowStatePtr { if let Some(scale) = scale { state.scale = scale; } + state.update_accesskit_window_bounds(); let device_bounds = state.bounds.to_device_pixels(state.scale); state.renderer.update_drawable_size(device_bounds.size); (state.bounds.size, state.scale) @@ -1504,7 +1529,8 @@ impl PlatformWindow for WaylandWindow { } fn a11y_update_window_bounds(&self) { - // Wayland does not expose window position. + let mut state = self.borrow_mut(); + state.update_accesskit_window_bounds(); } } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 852c3d9..247b551 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -1692,7 +1692,14 @@ impl LinuxClient for X11Client { fn is_cursor_visible(&self) -> bool { let state = self.0.borrow(); - is_cursor_visible(state.cursor_hidden_window, state.mouse_focused_window) + let focused_cursor_style = state + .mouse_focused_window + .and_then(|window| state.cursor_styles.get(&window).copied()); + is_cursor_visible( + state.cursor_hidden_window, + state.mouse_focused_window, + focused_cursor_style, + ) } fn open_uri(&self, uri: &str) { @@ -2510,7 +2517,12 @@ fn make_scroll_wheel_event( fn is_cursor_visible( cursor_hidden_window: Option, mouse_focused_window: Option, + focused_cursor_style: Option, ) -> bool { + if focused_cursor_style == Some(CursorStyle::None) { + return false; + } + !matches!( (cursor_hidden_window, mouse_focused_window), (Some(hidden_window), Some(focused_window)) if hidden_window == focused_window @@ -2823,10 +2835,11 @@ mod tests { #[test] fn cursor_is_visible_unless_hidden_window_is_focused_window() { - assert!(is_cursor_visible(None, None)); - assert!(is_cursor_visible(Some(1), None)); - assert!(is_cursor_visible(None, Some(1))); - assert!(is_cursor_visible(Some(1), Some(2))); - assert!(!is_cursor_visible(Some(1), Some(1))); + assert!(is_cursor_visible(None, None, None)); + assert!(is_cursor_visible(Some(1), None, None)); + assert!(is_cursor_visible(None, Some(1), None)); + assert!(is_cursor_visible(Some(1), Some(2), None)); + assert!(!is_cursor_visible(Some(1), Some(1), None)); + assert!(!is_cursor_visible(None, Some(1), Some(CursorStyle::None))); } } diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index caf0f6e..4018c40 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1068,15 +1068,15 @@ impl X11WindowStatePtr { .chunks_exact(4) .map(|chunk| u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); - state.active = false; state.fullscreen = false; state.maximized_vertical = false; state.maximized_horizontal = false; state.hidden = false; + let mut active = false; for atom in atoms { if atom == state.atoms._NET_WM_STATE_FOCUSED { - state.active = true; + active = true; } else if atom == state.atoms._NET_WM_STATE_FULLSCREEN { state.fullscreen = true; } else if atom == state.atoms._NET_WM_STATE_MAXIMIZED_VERT { @@ -1088,6 +1088,13 @@ impl X11WindowStatePtr { } } + let active_changed = state.active != active; + drop(state); + + if active_changed { + self.set_active(active); + } + Ok(()) } diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index c965c52..170bd9e 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -388,7 +388,7 @@ impl Platform for WebPlatform { } fn is_cursor_visible(&self) -> bool { - self.cursor_visible.get() + self.cursor_visible.get() && self.last_cursor_css.get() != "none" } fn should_auto_hide_scrollbars(&self) -> bool { diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index 52e9eac..94e8601 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -259,13 +259,12 @@ impl CosmicTextSystemState { .insert(fb_key.clone(), loaded.clone()); loaded }; - let Some(&fb_id) = fb_ids.first() else { - continue; - }; - let db_id = self.loaded_fonts[fb_id.0].font.id(); - if let Some(face) = self.font_system.db().face(db_id) { - if let Some(family) = face.families.first() { - chain.push((fb_id, SharedString::from(family.0.clone()))); + for fb_id in fb_ids { + let db_id = self.loaded_fonts[fb_id.0].font.id(); + if let Some(face) = self.font_system.db().face(db_id) { + if let Some(family) = face.families.first() { + chain.push((fb_id, SharedString::from(family.0.clone()))); + } } } } diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 896d1e3..40b4b87 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -415,6 +415,12 @@ impl WindowsWindow { invalidate_devices, } = creation_info; register_window_class(icon); + let display = if let Some(display_id) = params.display_id { + WindowsDisplay::new(display_id) + .with_context(|| format!("requested display {display_id:?} is not available"))? + } else { + WindowsDisplay::primary_monitor().context("failed to find any monitor")? + }; let parent_hwnd = if params.kind == WindowKind::Dialog { let parent_window = unsafe { GetActiveWindow() }; if parent_window.is_invalid() { @@ -469,12 +475,6 @@ impl WindowsWindow { } let hinstance = get_module_handle(); - let display = if let Some(display_id) = params.display_id { - WindowsDisplay::new(display_id) - .with_context(|| format!("requested display {display_id:?} is not available"))? - } else { - WindowsDisplay::primary_monitor().context("failed to find any monitor")? - }; let appearance = system_appearance().unwrap_or_default(); let mut context = WindowCreateContext { inner: None, From db275de1e9217ae53005ab09b8156793c8d498af Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Tue, 9 Jun 2026 12:46:09 -0700 Subject: [PATCH 22/22] Add clawpatch review config and fix two findings Consolidate duplicate poll logic in LogErrorFuture/LogErrorWithBacktraceFuture into a shared helper, and remove dead tilde-fallback in hyperfine lookup. --- .clawpatch/config.json | 38 ++++++++++ .../features/feat_cli-command_3b0436e283.json | 54 ++++++++++++++ .../features/feat_config_062b3c2b38.json | 49 ++++++++++++ .../features/feat_config_7afc533f22.json | 51 +++++++++++++ .../features/feat_config_902602e3e1.json | 52 +++++++++++++ .../features/feat_config_a5ac0cd10a.json | 49 ++++++++++++ .../features/feat_library_0aac2e5a47.json | 49 ++++++++++++ .../features/feat_library_12edbccd75.json | 47 ++++++++++++ .../features/feat_library_4d4b6dfac8.json | 49 ++++++++++++ .../features/feat_library_97901ecbe2.json | 47 ++++++++++++ .../features/feat_library_a4c19fb62c.json | 47 ++++++++++++ .../features/feat_test-suite_20a0f803f2.json | 37 ++++++++++ .../features/feat_test-suite_5659fe0bd7.json | 37 ++++++++++ .../features/feat_test-suite_6be57175d6.json | 37 ++++++++++ .../features/feat_test-suite_a20a893749.json | 37 ++++++++++ .../features/feat_test-suite_d062e8fe0e.json | 37 ++++++++++ ...at-cli-command-3b0436e283-_4f592efb4d.json | 66 +++++++++++++++++ ...at-config-7afc533f22-9ad41_c0175a193b.json | 42 +++++++++++ ...at-library-4d4b6dfac8-a6be_2065acefb3.json | 65 ++++++++++++++++ .clawpatch/project.json | 29 ++++++++ .clawpatch/reports/20260609T035033-f1797a.md | 74 +++++++++++++++++++ .clawpatch/runs/20260609T035033-f1797a.json | 41 ++++++++++ .clawpatch/runs/20260609T035322-d0d0ed.json | 25 +++++++ .clawpatch/runs/20260609T035322-e82086.json | 25 +++++++ crates/gpui_util/src/lib.rs | 57 +++++++------- tooling/perf/src/main.rs | 14 +--- 26 files changed, 1119 insertions(+), 36 deletions(-) create mode 100644 .clawpatch/config.json create mode 100644 .clawpatch/features/feat_cli-command_3b0436e283.json create mode 100644 .clawpatch/features/feat_config_062b3c2b38.json create mode 100644 .clawpatch/features/feat_config_7afc533f22.json create mode 100644 .clawpatch/features/feat_config_902602e3e1.json create mode 100644 .clawpatch/features/feat_config_a5ac0cd10a.json create mode 100644 .clawpatch/features/feat_library_0aac2e5a47.json create mode 100644 .clawpatch/features/feat_library_12edbccd75.json create mode 100644 .clawpatch/features/feat_library_4d4b6dfac8.json create mode 100644 .clawpatch/features/feat_library_97901ecbe2.json create mode 100644 .clawpatch/features/feat_library_a4c19fb62c.json create mode 100644 .clawpatch/features/feat_test-suite_20a0f803f2.json create mode 100644 .clawpatch/features/feat_test-suite_5659fe0bd7.json create mode 100644 .clawpatch/features/feat_test-suite_6be57175d6.json create mode 100644 .clawpatch/features/feat_test-suite_a20a893749.json create mode 100644 .clawpatch/features/feat_test-suite_d062e8fe0e.json create mode 100644 .clawpatch/findings/fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d.json create mode 100644 .clawpatch/findings/fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b.json create mode 100644 .clawpatch/findings/fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3.json create mode 100644 .clawpatch/project.json create mode 100644 .clawpatch/reports/20260609T035033-f1797a.md create mode 100644 .clawpatch/runs/20260609T035033-f1797a.json create mode 100644 .clawpatch/runs/20260609T035322-d0d0ed.json create mode 100644 .clawpatch/runs/20260609T035322-e82086.json diff --git a/.clawpatch/config.json b/.clawpatch/config.json new file mode 100644 index 0000000..3da68a3 --- /dev/null +++ b/.clawpatch/config.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "stateDir": ".clawpatch", + "include": [ + "**/*" + ], + "exclude": [ + "node_modules/**", + "dist/**", + "build/**", + "target/**", + ".build/**", + ".git/**", + ".clawpatch/**" + ], + "provider": { + "name": "codex", + "model": null, + "reasoningEffort": null + }, + "commands": { + "typecheck": "cargo check --workspace --all-targets", + "lint": null, + "format": "cargo fmt --all --check", + "test": "cargo test --workspace" + }, + "review": { + "maxContextFiles": 24, + "maxOwnedFiles": 12, + "maxFindingsPerFeature": 10, + "minConfidenceToFix": "medium" + }, + "git": { + "requireCleanWorktreeForFix": true, + "commit": false, + "openPr": false + } +} diff --git a/.clawpatch/features/feat_cli-command_3b0436e283.json b/.clawpatch/features/feat_cli-command_3b0436e283.json new file mode 100644 index 0000000..88f7878 --- /dev/null +++ b/.clawpatch/features/feat_cli-command_3b0436e283.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 1, + "featureId": "feat_cli-command_3b0436e283", + "title": "Rust command perf", + "summary": "Rust executable command at tooling/perf/src/main.rs.", + "kind": "cli-command", + "source": "rust-command", + "confidence": "high", + "entrypoints": [ + { + "path": "tooling/perf/src/main.rs", + "symbol": "main", + "route": null, + "command": "perf" + } + ], + "ownedFiles": [ + { + "path": "tooling/perf/src/main.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "cli" + ], + "trustBoundaries": [ + "user-input", + "filesystem", + "process-exec", + "network" + ], + "status": "fixed", + "lock": null, + "findingIds": [ + "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d" + ], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "1 finding(s); prompt=31227 bytes; approxTokens=7807; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:53.516Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:55:04.397Z" +} diff --git a/.clawpatch/features/feat_config_062b3c2b38.json b/.clawpatch/features/feat_config_062b3c2b38.json new file mode 100644 index 0000000..0b2d3e4 --- /dev/null +++ b/.clawpatch/features/feat_config_062b3c2b38.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "featureId": "feat_config_062b3c2b38", + "title": "Project config rust-toolchain.toml", + "summary": "Build, release, or quality configuration in rust-toolchain.toml.", + "kind": "config", + "source": "shared-infra-heuristic", + "confidence": "medium", + "entrypoints": [ + { + "path": "rust-toolchain.toml", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "rust-toolchain.toml", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "config" + ], + "trustBoundaries": [ + "process-exec", + "filesystem" + ], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=6459 bytes; approxTokens=1615; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:41.521Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:41.521Z" +} diff --git a/.clawpatch/features/feat_config_7afc533f22.json b/.clawpatch/features/feat_config_7afc533f22.json new file mode 100644 index 0000000..d53236f --- /dev/null +++ b/.clawpatch/features/feat_config_7afc533f22.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "featureId": "feat_config_7afc533f22", + "title": "Project config Cargo.toml", + "summary": "Build, release, or quality configuration in Cargo.toml.", + "kind": "config", + "source": "shared-infra-heuristic", + "confidence": "medium", + "entrypoints": [ + { + "path": "Cargo.toml", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "Cargo.toml", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "config" + ], + "trustBoundaries": [ + "process-exec", + "filesystem" + ], + "status": "fixed", + "lock": null, + "findingIds": [ + "fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b" + ], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "1 finding(s); prompt=16972 bytes; approxTokens=4243; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:53.565Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:55:43.285Z" +} diff --git a/.clawpatch/features/feat_config_902602e3e1.json b/.clawpatch/features/feat_config_902602e3e1.json new file mode 100644 index 0000000..939773c --- /dev/null +++ b/.clawpatch/features/feat_config_902602e3e1.json @@ -0,0 +1,52 @@ +{ + "schemaVersion": 1, + "featureId": "feat_config_902602e3e1", + "title": "Shell/workflow config ci.yml", + "summary": "Shell or workflow automation in .github/workflows/ci.yml, including command substitutions and machine-readable command output.", + "kind": "config", + "source": "shell-workflow-heuristic", + "confidence": "medium", + "entrypoints": [ + { + "path": ".github/workflows/ci.yml", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": ".github/workflows/ci.yml", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "config", + "shell", + "workflow" + ], + "trustBoundaries": [ + "process-exec", + "filesystem", + "network" + ], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=8103 bytes; approxTokens=2026; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:45.055Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:45.055Z" +} diff --git a/.clawpatch/features/feat_config_a5ac0cd10a.json b/.clawpatch/features/feat_config_a5ac0cd10a.json new file mode 100644 index 0000000..b427100 --- /dev/null +++ b/.clawpatch/features/feat_config_a5ac0cd10a.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "featureId": "feat_config_a5ac0cd10a", + "title": "Project config Cargo.lock", + "summary": "Build, release, or quality configuration in Cargo.lock.", + "kind": "config", + "source": "shared-infra-heuristic", + "confidence": "medium", + "entrypoints": [ + { + "path": "Cargo.lock", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "Cargo.lock", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "config" + ], + "trustBoundaries": [ + "process-exec", + "filesystem" + ], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=35993 bytes; approxTokens=8999; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:43.599Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:43.599Z" +} diff --git a/.clawpatch/features/feat_library_0aac2e5a47.json b/.clawpatch/features/feat_library_0aac2e5a47.json new file mode 100644 index 0000000..0ed8a7c --- /dev/null +++ b/.clawpatch/features/feat_library_0aac2e5a47.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_0aac2e5a47", + "title": "C/C++ source group crates", + "summary": "C/C++/CUDA source files under crates not owned by a build target.", + "kind": "library", + "source": "c-cpp-group", + "confidence": "low", + "entrypoints": [ + { + "path": "crates/media/src/bindings.h", + "symbol": "crates", + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/media/src/bindings.h", + "reason": "source group member" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "c", + "source-group" + ], + "trustBoundaries": [ + "filesystem" + ], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=6495 bytes; approxTokens=1624; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:41.965Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:41.965Z" +} diff --git a/.clawpatch/features/feat_library_12edbccd75.json b/.clawpatch/features/feat_library_12edbccd75.json new file mode 100644 index 0000000..35098c5 --- /dev/null +++ b/.clawpatch/features/feat_library_12edbccd75.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_12edbccd75", + "title": "Rust library perf", + "summary": "Rust library crate at tooling/perf/src/lib.rs.", + "kind": "library", + "source": "rust-library", + "confidence": "high", + "entrypoints": [ + { + "path": "tooling/perf/src/lib.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "tooling/perf/src/lib.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "library" + ], + "trustBoundaries": [], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=6474 bytes; approxTokens=1619; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:40.801Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:40.801Z" +} diff --git a/.clawpatch/features/feat_library_4d4b6dfac8.json b/.clawpatch/features/feat_library_4d4b6dfac8.json new file mode 100644 index 0000000..29b2204 --- /dev/null +++ b/.clawpatch/features/feat_library_4d4b6dfac8.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_4d4b6dfac8", + "title": "Rust library gpui_util", + "summary": "Rust library crate at crates/gpui_util/src/lib.rs.", + "kind": "library", + "source": "rust-library", + "confidence": "high", + "entrypoints": [ + { + "path": "crates/gpui_util/src/lib.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui_util/src/lib.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "library" + ], + "trustBoundaries": [], + "status": "fixed", + "lock": null, + "findingIds": [ + "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3" + ], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "1 finding(s); prompt=19308 bytes; approxTokens=4827; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:53.499Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:54:58.836Z" +} diff --git a/.clawpatch/features/feat_library_97901ecbe2.json b/.clawpatch/features/feat_library_97901ecbe2.json new file mode 100644 index 0000000..30e7d7e --- /dev/null +++ b/.clawpatch/features/feat_library_97901ecbe2.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_97901ecbe2", + "title": "Rust library ztracing_macro", + "summary": "Rust library crate at crates/ztracing_macro/src/lib.rs.", + "kind": "library", + "source": "rust-library", + "confidence": "high", + "entrypoints": [ + { + "path": "crates/ztracing_macro/src/lib.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/ztracing_macro/src/lib.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "library" + ], + "trustBoundaries": [], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=6440 bytes; approxTokens=1610; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:41.967Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:41.967Z" +} diff --git a/.clawpatch/features/feat_library_a4c19fb62c.json b/.clawpatch/features/feat_library_a4c19fb62c.json new file mode 100644 index 0000000..34cd30c --- /dev/null +++ b/.clawpatch/features/feat_library_a4c19fb62c.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_a4c19fb62c", + "title": "Rust library ztracing", + "summary": "Rust library crate at crates/ztracing/src/lib.rs.", + "kind": "library", + "source": "rust-library", + "confidence": "high", + "entrypoints": [ + { + "path": "crates/ztracing/src/lib.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/ztracing/src/lib.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "library" + ], + "trustBoundaries": [], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=9027 bytes; approxTokens=2257; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:51:12.464Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:51:12.464Z" +} diff --git a/.clawpatch/features/feat_test-suite_20a0f803f2.json b/.clawpatch/features/feat_test-suite_20a0f803f2.json new file mode 100644 index 0000000..ba33de5 --- /dev/null +++ b/.clawpatch/features/feat_test-suite_20a0f803f2.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_20a0f803f2", + "title": "Rust integration test gpui/action_macros", + "summary": "Rust integration test entrypoint at crates/gpui/tests/action_macros.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui/tests/action_macros.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui/tests/action_macros.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/features/feat_test-suite_5659fe0bd7.json b/.clawpatch/features/feat_test-suite_5659fe0bd7.json new file mode 100644 index 0000000..8c0d47b --- /dev/null +++ b/.clawpatch/features/feat_test-suite_5659fe0bd7.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_5659fe0bd7", + "title": "Rust integration test gpui_macros/derive_context", + "summary": "Rust integration test entrypoint at crates/gpui_macros/tests/derive_context.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui_macros/tests/derive_context.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui_macros/tests/derive_context.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/features/feat_test-suite_6be57175d6.json b/.clawpatch/features/feat_test-suite_6be57175d6.json new file mode 100644 index 0000000..e625c64 --- /dev/null +++ b/.clawpatch/features/feat_test-suite_6be57175d6.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_6be57175d6", + "title": "Rust integration test gpui_macros/derive_inspector_reflection", + "summary": "Rust integration test entrypoint at crates/gpui_macros/tests/derive_inspector_reflection.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui_macros/tests/derive_inspector_reflection.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui_macros/tests/derive_inspector_reflection.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/features/feat_test-suite_a20a893749.json b/.clawpatch/features/feat_test-suite_a20a893749.json new file mode 100644 index 0000000..dcc1a94 --- /dev/null +++ b/.clawpatch/features/feat_test-suite_a20a893749.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_a20a893749", + "title": "Rust integration test gpui_macros/render_test", + "summary": "Rust integration test entrypoint at crates/gpui_macros/tests/render_test.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui_macros/tests/render_test.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui_macros/tests/render_test.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/features/feat_test-suite_d062e8fe0e.json b/.clawpatch/features/feat_test-suite_d062e8fe0e.json new file mode 100644 index 0000000..09b46c1 --- /dev/null +++ b/.clawpatch/features/feat_test-suite_d062e8fe0e.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_d062e8fe0e", + "title": "Rust integration test gpui/cursor_style", + "summary": "Rust integration test entrypoint at crates/gpui/tests/cursor_style.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui/tests/cursor_style.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui/tests/cursor_style.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/findings/fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d.json b/.clawpatch/findings/fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d.json new file mode 100644 index 0000000..8097cd0 --- /dev/null +++ b/.clawpatch/findings/fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "findingId": "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d", + "featureId": "feat_cli-command_3b0436e283", + "title": "Dead tilde fallback in hyperfine lookup", + "category": "maintainability", + "severity": "low", + "confidence": "high", + "triage": "risk", + "evidence": [ + { + "path": "tooling/perf/src/main.rs", + "startLine": 416, + "endLine": 429, + "symbol": "hyp_binary", + "quote": null + } + ], + "reasoning": "`Command::new` does not run through a shell, so `~/.cargo/bin/hyperfine` is treated as a literal executable path. That fallback cannot find the normal Cargo bin install location, yet every lookup carries the extra branch and failed process attempt. Setup docs already require `hyperfine` in PATH, so this path is accidental complexity with runtime cost and misleading maintenance surface.", + "reproduction": null, + "recommendation": "Delete the `HYP_HOME` fallback and keep one PATH lookup, or replace it with a real absolute home-dir lookup only if non-PATH installs are a supported contract.", + "whyTestsDoNotAlreadyCoverThis": "No linked tests exercise hyperfine discovery, and deslopify review is limited to this owned file excerpt.", + "suggestedRegressionTest": null, + "minimumFixScope": "Simplify `hyp_binary` to one `Command::new(\"hyperfine\")` probe unless repo wants explicit absolute-path discovery.", + "status": "fixed", + "history": [ + { + "runId": "20260609T035322-e82086", + "kind": "revalidate", + "status": "fixed", + "note": null, + "reasoning": "Original evidence path still exists at tooling/perf/src/main.rs:416. Current hyp_binary is lines 417-423 and now probes only Command::new(\"hyperfine\") via PATH, then returns Command::new(\"hyperfine\"). HYP_HOME and \"~/.cargo/bin/hyperfine\" no longer appear in tooling/Cargo.toml/README.md search surface. Setup comment still says hyperfine must be in shell path, so current code matches stated contract. git diff shows dirty worktree change deleting original tilde fallback, so fixed in current repository but not necessarily committed. cargo fmt --all --check and git diff --check pass. cargo check/test could not run because read-only sandbox cannot open target/debug/.cargo-lock: \"Operation not permitted (os error 1)\". clawpatch revalidate CLI also could not write .clawpatch/runs tmp file due EPERM.", + "commands": [ + "test -f ~/.agents/AGENTS.local.md && sed -n '1,220p' ~/.agents/AGENTS.local.md || true", + "sed -n '1,220p' /Users/adityasharma/Projects/dotfiles/skills/clawpatch/SKILL.md", + "sed -n '1,220p' /Users/adityasharma/Projects/dotfiles/skills/verify-this/SKILL.md", + "git status --short", + "test -f AGENTS.md && sed -n '1,220p' AGENTS.md || true", + "test -f AGENTS.local.md && sed -n '1,220p' AGENTS.local.md || true", + "clawpatch --help", + "clawpatch revalidate --help", + "nl -ba tooling/perf/src/main.rs | sed -n '390,450p'", + "rg -n \"hyperfine|hyp_binary|HYP_HOME|cargo/bin\" tooling/perf src crates docs README.md Cargo.toml .clawpatch 2>/dev/null", + "git diff -- tooling/perf/src/main.rs", + "clawpatch show --finding fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d --json", + "sed -n '1,220p' tooling/perf/Cargo.toml", + "ast-grep --lang rust -p 'Command::new($ARG)' tooling/perf/src/main.rs", + "sed -n '1,32p' tooling/perf/src/main.rs", + "rg -n \"\\[\\[bin\\]\\]|name = \\\"perf\\\"|tooling/perf\" Cargo.toml tooling/perf/Cargo.toml .cargo/config.toml 2>/dev/null", + "cargo fmt --all --check", + "cargo test -p perf", + "cargo check -p perf --all-targets", + "clawpatch revalidate --finding fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d --include-dirty --reasoning-effort high --json", + "rg -n \"HYP_HOME|~/.cargo/bin/hyperfine\" tooling Cargo.toml README.md", + "rg -n \"Command::new\\(|hyp_binary|hyperfine\" tooling/perf/src/main.rs", + "git diff --check -- tooling/perf/src/main.rs" + ], + "createdAt": "2026-06-09T03:55:04.389Z" + } + ], + "signature": "sig_feat-cli-command-3b0436e283_f4f2e4dad9", + "linkedPatchAttemptIds": [], + "createdByRunId": "20260609T035033-f1797a", + "createdAt": "2026-06-09T03:50:53.515Z", + "updatedAt": "2026-06-09T03:55:04.389Z" +} diff --git a/.clawpatch/findings/fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b.json b/.clawpatch/findings/fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b.json new file mode 100644 index 0000000..95968ee --- /dev/null +++ b/.clawpatch/findings/fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "findingId": "fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b", + "featureId": "feat_config_7afc533f22", + "title": "Duplicate Windows feature entry", + "category": "maintainability", + "severity": "low", + "confidence": "high", + "triage": "risk", + "evidence": [ + { + "path": "Cargo.toml", + "startLine": 190, + "endLine": 240, + "symbol": null, + "quote": null + } + ], + "reasoning": "`System_Threading` appears twice in the same `windows` feature list. Cargo feature unification makes the second entry redundant, so it only adds noise to an already large manual feature registry and increases review cost when auditing Windows API surface.", + "reproduction": null, + "recommendation": "Delete the duplicate `System_Threading` feature entry and keep the list single-source within this table.", + "whyTestsDoNotAlreadyCoverThis": "No tests are linked for this config-only feature, and normal builds would not fail because Cargo de-duplicates repeated features.", + "suggestedRegressionTest": null, + "minimumFixScope": "Remove one duplicate line from `[workspace.dependencies.windows].features`.", + "status": "false-positive", + "history": [ + { + "runId": null, + "kind": "triage", + "status": "false-positive", + "note": "Verified against current Cargo.toml and the local windows 0.61.3 registry manifest. Cargo.toml contains exactly one \"System_Threading\" entry at line 194 and one distinct \"Win32_System_Threading\" entry at line 228. The windows 0.61.3 Cargo.toml defines both as separate features: System_Threading = [\"System\"] and Win32_System_Threading = [\"Win32_System\"]. No duplicate exact feature entry exists.", + "reasoning": null, + "commands": [], + "createdAt": "2026-06-09T03:55:43.277Z" + } + ], + "signature": "sig_feat-config-7afc533f22_9ad4120894", + "linkedPatchAttemptIds": [], + "createdByRunId": "20260609T035033-f1797a", + "createdAt": "2026-06-09T03:50:53.564Z", + "updatedAt": "2026-06-09T03:55:43.276Z" +} diff --git a/.clawpatch/findings/fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3.json b/.clawpatch/findings/fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3.json new file mode 100644 index 0000000..1287198 --- /dev/null +++ b/.clawpatch/findings/fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": 1, + "findingId": "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3", + "featureId": "feat_library_4d4b6dfac8", + "title": "Backtrace future wrapper duplicates normal error logging future", + "category": "maintainability", + "severity": "low", + "confidence": "high", + "triage": "risk", + "evidence": [ + { + "path": "crates/gpui_util/src/lib.rs", + "startLine": 301, + "endLine": 326, + "symbol": "LogErrorFuture", + "quote": null + }, + { + "path": "crates/gpui_util/src/lib.rs", + "startLine": 328, + "endLine": 352, + "symbol": "LogErrorWithBacktraceFuture", + "quote": null + } + ], + "reasoning": "LogErrorFuture and LogErrorWithBacktraceFuture have the same state layout and identical poll control flow. Only the error formatting differs at the logging call. This creates duplicate unsafe pin-projection code, so any future pin-safety or polling behavior change must be made twice and kept in sync.", + "reproduction": null, + "recommendation": "Consolidate both wrappers into one local future implementation with a small formatting mode or shared poll helper, reusing DebugAsDisplay only at the error-formatting boundary.", + "whyTestsDoNotAlreadyCoverThis": "No linked tests are included for this feature, and behavior tests would not detect maintenance cost from duplicated unsafe poll code.", + "suggestedRegressionTest": null, + "minimumFixScope": "crates/gpui_util/src/lib.rs", + "status": "fixed", + "history": [ + { + "runId": "20260609T035322-d0d0ed", + "kind": "revalidate", + "status": "fixed", + "note": null, + "reasoning": "Current code no longer has duplicated poll control flow from original evidence. LogErrorFuture still starts at crates/gpui_util/src/lib.rs:301, LogErrorWithBacktraceFuture moved to :320, and both poll impls now delegate to shared poll_log_error_future at :337-358. Only formatting closures differ at :313-315 and :331-333. git diff confirms duplicate match/Poll blocks were removed and centralized. cargo fmt passes. cargo check/test could not run because read-only sandbox cannot open target/debug/.cargo-lock, but this maintainability finding is resolved by current code evidence.", + "commands": [ + "git status --short", + "rg -n \"LogErrorFuture|LogErrorWithBacktraceFuture|log_err|log_err_with_backtrace|DebugAsDisplay\" crates/gpui_util/src/lib.rs", + "nl -ba crates/gpui_util/src/lib.rs | sed -n '260,370p'", + "ast-grep --lang rust -p 'struct LogErrorFuture<$A, $B> { $$$ }' crates/gpui_util/src/lib.rs && ast-grep --lang rust -p 'struct LogErrorWithBacktraceFuture<$A, $B> { $$$ }' crates/gpui_util/src/lib.rs", + "ast-grep --lang rust -p 'impl<$A, $B> Future for $T { $$$ }' crates/gpui_util/src/lib.rs", + "cargo test -p gpui_util --lib", + "git diff -- crates/gpui_util/src/lib.rs", + "rg -n \"fn poll_log_error_future|Pin::new_unchecked|unsafe \\\\{ self.get_unchecked_mut\\\\(\\\\) \\\\}\" crates/gpui_util/src/lib.rs", + "clawpatch --help | sed -n '1,120p'", + "clawpatch revalidate --help | sed -n '1,160p'", + "rg -n \"fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3|LogErrorWithBacktraceFuture|poll_log_error_future\" .clawpatch crates/gpui_util/src/lib.rs", + "find .clawpatch -maxdepth 3 -type f | sed -n '1,120p'", + "clawpatch show --finding fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3 --json", + "cargo fmt --all --check", + "cargo check -p gpui_util" + ], + "createdAt": "2026-06-09T03:54:58.830Z" + } + ], + "signature": "sig_feat-library-4d4b6dfac8_a6be76b1de", + "linkedPatchAttemptIds": [], + "createdByRunId": "20260609T035033-f1797a", + "createdAt": "2026-06-09T03:50:53.497Z", + "updatedAt": "2026-06-09T03:54:58.830Z" +} diff --git a/.clawpatch/project.json b/.clawpatch/project.json new file mode 100644 index 0000000..72bc65e --- /dev/null +++ b/.clawpatch/project.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "projectId": "prj_https-github-com-bumpyclock-gpui_10c6d8e5b4", + "name": "gpui", + "rootPath": "/Users/adityasharma/Projects/gpui", + "git": { + "remoteUrl": "https://github.com/BumpyClock/gpui.git", + "defaultBranch": "main", + "currentBranch": "05-15-26-upstream-sync", + "headSha": "1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0" + }, + "detected": { + "languages": [ + "rust" + ], + "frameworks": [], + "packageManagers": [ + "cargo" + ], + "commands": { + "typecheck": "cargo check --workspace --all-targets", + "lint": null, + "format": "cargo fmt --all --check", + "test": "cargo test --workspace" + } + }, + "createdAt": "2026-06-09T03:50:20.073Z", + "updatedAt": "2026-06-09T03:50:20.073Z" +} diff --git a/.clawpatch/reports/20260609T035033-f1797a.md b/.clawpatch/reports/20260609T035033-f1797a.md new file mode 100644 index 0000000..40383f0 --- /dev/null +++ b/.clawpatch/reports/20260609T035033-f1797a.md @@ -0,0 +1,74 @@ +# clawpatch report + +findings: 3 + +## low: Backtrace future wrapper duplicates normal error logging future + +id: fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3 +category: maintainability +confidence: high +triage: risk +status: open +feature: Rust library gpui_util (feat_library_4d4b6dfac8) + +evidence: +- crates/gpui_util/src/lib.rs:301-326 (LogErrorFuture) +- crates/gpui_util/src/lib.rs:328-352 (LogErrorWithBacktraceFuture) + +LogErrorFuture and LogErrorWithBacktraceFuture have the same state layout and identical poll control flow. Only the error formatting differs at the logging call. This creates duplicate unsafe pin-projection code, so any future pin-safety or polling behavior change must be made twice and kept in sync. + +recommendation: +Consolidate both wrappers into one local future implementation with a small formatting mode or shared poll helper, reusing DebugAsDisplay only at the error-formatting boundary. + +test analysis: +No linked tests are included for this feature, and behavior tests would not detect maintenance cost from duplicated unsafe poll code. + +minimum fix scope: +crates/gpui_util/src/lib.rs + +## low: Dead tilde fallback in hyperfine lookup + +id: fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d +category: maintainability +confidence: high +triage: risk +status: open +feature: Rust command perf (feat_cli-command_3b0436e283) + +evidence: +- tooling/perf/src/main.rs:416-429 (hyp_binary) + +`Command::new` does not run through a shell, so `~/.cargo/bin/hyperfine` is treated as a literal executable path. That fallback cannot find the normal Cargo bin install location, yet every lookup carries the extra branch and failed process attempt. Setup docs already require `hyperfine` in PATH, so this path is accidental complexity with runtime cost and misleading maintenance surface. + +recommendation: +Delete the `HYP_HOME` fallback and keep one PATH lookup, or replace it with a real absolute home-dir lookup only if non-PATH installs are a supported contract. + +test analysis: +No linked tests exercise hyperfine discovery, and deslopify review is limited to this owned file excerpt. + +minimum fix scope: +Simplify `hyp_binary` to one `Command::new("hyperfine")` probe unless repo wants explicit absolute-path discovery. + +## low: Duplicate Windows feature entry + +id: fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b +category: maintainability +confidence: high +triage: risk +status: open +feature: Project config Cargo.toml (feat_config_7afc533f22) + +evidence: +- Cargo.toml:190-240 + +`System_Threading` appears twice in the same `windows` feature list. Cargo feature unification makes the second entry redundant, so it only adds noise to an already large manual feature registry and increases review cost when auditing Windows API surface. + +recommendation: +Delete the duplicate `System_Threading` feature entry and keep the list single-source within this table. + +test analysis: +No tests are linked for this config-only feature, and normal builds would not fail because Cargo de-duplicates repeated features. + +minimum fix scope: +Remove one duplicate line from `[workspace.dependencies.windows].features`. + diff --git a/.clawpatch/runs/20260609T035033-f1797a.json b/.clawpatch/runs/20260609T035033-f1797a.json new file mode 100644 index 0000000..fc85e99 --- /dev/null +++ b/.clawpatch/runs/20260609T035033-f1797a.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "runId": "20260609T035033-f1797a", + "command": "review", + "args": [ + "review", + "--mode", + "deslopify", + "--limit", + "10", + "--reasoning-effort", + "high", + "--jobs", + "10", + "--json" + ], + "rootPath": "/Users/adityasharma/Projects/gpui", + "headSha": "1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0", + "startedAt": "2026-06-09T03:50:33.118Z", + "finishedAt": "2026-06-09T03:51:12.465Z", + "status": "completed", + "claimedFeatureIds": [ + "feat_cli-command_3b0436e283", + "feat_config_062b3c2b38", + "feat_config_7afc533f22", + "feat_config_902602e3e1", + "feat_config_a5ac0cd10a", + "feat_library_0aac2e5a47", + "feat_library_12edbccd75", + "feat_library_4d4b6dfac8", + "feat_library_97901ecbe2", + "feat_library_a4c19fb62c" + ], + "findingIds": [ + "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3", + "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d", + "fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b" + ], + "patchAttemptIds": [], + "errors": [] +} diff --git a/.clawpatch/runs/20260609T035322-d0d0ed.json b/.clawpatch/runs/20260609T035322-d0d0ed.json new file mode 100644 index 0000000..d1c0426 --- /dev/null +++ b/.clawpatch/runs/20260609T035322-d0d0ed.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "runId": "20260609T035322-d0d0ed", + "command": "revalidate", + "args": [ + "revalidate", + "--finding", + "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3", + "--include-dirty", + "--reasoning-effort", + "high", + "--json" + ], + "rootPath": "/Users/adityasharma/Projects/gpui", + "headSha": "1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0", + "startedAt": "2026-06-09T03:53:22.701Z", + "finishedAt": "2026-06-09T03:54:58.836Z", + "status": "completed", + "claimedFeatureIds": [], + "findingIds": [ + "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3" + ], + "patchAttemptIds": [], + "errors": [] +} diff --git a/.clawpatch/runs/20260609T035322-e82086.json b/.clawpatch/runs/20260609T035322-e82086.json new file mode 100644 index 0000000..b878b1b --- /dev/null +++ b/.clawpatch/runs/20260609T035322-e82086.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "runId": "20260609T035322-e82086", + "command": "revalidate", + "args": [ + "revalidate", + "--finding", + "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d", + "--include-dirty", + "--reasoning-effort", + "high", + "--json" + ], + "rootPath": "/Users/adityasharma/Projects/gpui", + "headSha": "1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0", + "startedAt": "2026-06-09T03:53:22.701Z", + "finishedAt": "2026-06-09T03:55:04.398Z", + "status": "completed", + "claimedFeatureIds": [], + "findingIds": [ + "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d" + ], + "patchAttemptIds": [], + "errors": [] +} diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs index 5707d67..25b1a1e 100644 --- a/crates/gpui_util/src/lib.rs +++ b/crates/gpui_util/src/lib.rs @@ -309,19 +309,10 @@ where type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - let level = self.1; - let location = self.2; - let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; - match inner.poll(cx) { - Poll::Ready(output) => Poll::Ready(match output { - Ok(output) => Some(output), - Err(error) => { - log_error_with_caller(location, error, level); - None - } - }), - Poll::Pending => Poll::Pending, - } + let this = unsafe { self.get_unchecked_mut() }; + poll_log_error_future(&mut this.0, cx, this.1, this.2, |location, error, level| { + log_error_with_caller(location, error, level); + }) } } @@ -336,19 +327,33 @@ where type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - let level = self.1; - let location = self.2; - let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; - match inner.poll(cx) { - Poll::Ready(output) => Poll::Ready(match output { - Ok(output) => Some(output), - Err(error) => { - log_error_with_caller(location, DebugAsDisplay(&error), level); - None - } - }), - Poll::Pending => Poll::Pending, - } + let this = unsafe { self.get_unchecked_mut() }; + poll_log_error_future(&mut this.0, cx, this.1, this.2, |location, error, level| { + log_error_with_caller(location, DebugAsDisplay(&error), level); + }) + } +} + +fn poll_log_error_future( + future: &mut F, + cx: &mut Context, + level: log::Level, + location: core::panic::Location<'static>, + log_error: impl FnOnce(core::panic::Location<'static>, E, log::Level), +) -> Poll> +where + F: Future>, +{ + let inner = unsafe { Pin::new_unchecked(future) }; + match inner.poll(cx) { + Poll::Ready(output) => Poll::Ready(match output { + Ok(output) => Some(output), + Err(error) => { + log_error(location, error, level); + None + } + }), + Poll::Pending => Poll::Pending, } } diff --git a/tooling/perf/src/main.rs b/tooling/perf/src/main.rs index 243658e..728f61a 100644 --- a/tooling/perf/src/main.rs +++ b/tooling/perf/src/main.rs @@ -416,16 +416,10 @@ fn triage_test( /// Try to find the hyperfine binary the user has installed. fn hyp_binary() -> Option { const HYP_PATH: &str = "hyperfine"; - const HYP_HOME: &str = "~/.cargo/bin/hyperfine"; - if Command::new(HYP_PATH).output().is_err() { - if Command::new(HYP_HOME).output().is_err() { - None - } else { - Some(Command::new(HYP_HOME)) - } - } else { - Some(Command::new(HYP_PATH)) - } + Command::new(HYP_PATH) + .output() + .is_ok() + .then(|| Command::new(HYP_PATH)) } /// Profiles a given test with hyperfine, returning the mean and standard deviation