From f472fecff5f293d2d6e284ddb3b3e3eca24a163b Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Thu, 14 May 2026 15:31:20 -0700 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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);