From ab324ab9dcfd4ff2174167ebfa4a5edddefa3141 Mon Sep 17 00:00:00 2001 From: John Tur Date: Mon, 24 Aug 2026 02:59:15 +0000 Subject: [PATCH 01/45] gpui: Support Windows Restart Manager shutdown (#62987) Handle the `WM_QUERYENDSESSION` and `WM_ENDSESSION` messages, which are used by Windows when shutting down an application. For example, Restart Manager uses this mechanism to gracefully close applications (e.g. when installing an update to the application). Release Notes: - N/A --- crates/gpui/src/app.rs | 15 ++++++++-- crates/gpui/src/platform.rs | 2 +- crates/gpui/src/platform/test/platform.rs | 2 +- crates/gpui/src/platform/visual_test.rs | 2 +- crates/gpui_linux/src/linux/platform.rs | 4 +-- crates/gpui_macos/src/platform.rs | 4 +-- crates/gpui_windows/src/events.rs | 17 ++++++++++++ crates/gpui_windows/src/platform.rs | 34 +++++++++++++++++++---- 8 files changed, 66 insertions(+), 14 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 08f4be8..0c58c55 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -918,8 +918,19 @@ impl App { platform.on_quit(Box::new({ let cx = Rc::downgrade(&app); move || { - if let Some(cx) = cx.upgrade() { - cx.borrow_mut().shutdown(); + let Some(cx) = cx.upgrade() else { + return true; + }; + match cx.try_borrow_mut() { + Ok(mut cx) => { + cx.shutdown(); + true + } + Err(_) => { + // Quit was requested while the AppCell was borrowed, so we can't shut down synchronously. + // The platform decides how to proceed. + false + } } } })); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index ab4b500..bdafe8c 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -200,7 +200,7 @@ pub trait Platform: 'static { fn reveal_path(&self, path: &Path); fn open_with_system(&self, path: &Path); - fn on_quit(&self, callback: Box); + fn on_quit(&self, callback: Box bool>); fn on_reopen(&self, callback: Box); fn on_system_wake(&self, callback: Box); diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 3982190..86c2297 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -454,7 +454,7 @@ impl Platform for TestPlatform { unimplemented!() } - fn on_quit(&self, _callback: Box) {} + fn on_quit(&self, _callback: Box bool>) {} fn on_reopen(&self, _callback: Box) { unimplemented!() diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index 3ff561b..042a757 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -172,7 +172,7 @@ impl Platform for VisualTestPlatform { self.platform.open_with_system(path) } - fn on_quit(&self, _callback: Box) {} + fn on_quit(&self, _callback: Box bool>) {} fn on_reopen(&self, _callback: Box) {} diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 7e8ec22..53dec5d 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -106,7 +106,7 @@ pub(crate) trait LinuxClient { #[derive(Default)] pub(crate) struct PlatformHandlers { pub(crate) open_urls: Option)>>, - pub(crate) quit: Option>, + pub(crate) quit: Option bool>>, pub(crate) reopen: Option>, pub(crate) app_menu_action: Option>, pub(crate) will_open_app_menu: Option>, @@ -547,7 +547,7 @@ impl Platform for LinuxPlatform

{ .detach(); } - fn on_quit(&self, callback: Box) { + fn on_quit(&self, callback: Box bool>) { self.inner.with_common(|common| { common.callbacks.quit = Some(callback); }); diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 3ec54e8..c1d857c 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -178,7 +178,7 @@ pub(crate) struct MacPlatformState { on_thermal_state_change: Option>, on_system_wake: Option>, system_wake_observer_registered: bool, - quit: Option>, + quit: Option bool>>, menu_command: Option>, validate_menu_command: Option bool>>, will_open_menu: Option>, @@ -940,7 +940,7 @@ impl Platform for MacPlatform { .detach(); } - fn on_quit(&self, callback: Box) { + fn on_quit(&self, callback: Box bool>) { self.0.lock().quit = Some(callback); } diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 1bd1849..e700cb0 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -28,6 +28,7 @@ pub(crate) const WM_GPUI_FORCE_UPDATE_WINDOW: u32 = WM_USER + 5; pub(crate) const WM_GPUI_KEYBOARD_LAYOUT_CHANGED: u32 = WM_USER + 6; pub(crate) const WM_GPUI_GPU_DEVICE_LOST: u32 = WM_USER + 7; pub(crate) const WM_GPUI_KEYDOWN: u32 = WM_USER + 8; +pub(crate) const WM_GPUI_END_SESSION: u32 = WM_USER + 9; const SIZE_MOVE_LOOP_TIMER_ID: usize = 1; @@ -108,6 +109,8 @@ impl WindowsWindowInner { WM_PAINT => self.handle_paint_msg(handle), WM_CLOSE => self.handle_close_msg(), WM_DESTROY => self.handle_destroy_msg(handle), + WM_QUERYENDSESSION => Some(1), + WM_ENDSESSION => self.handle_end_session_msg(wparam), WM_MOUSEMOVE => self.handle_mouse_move_msg(handle, lparam, wparam), WM_MOUSELEAVE | WM_NCMOUSELEAVE => self.handle_mouse_leave_msg(), WM_NCMOUSEMOVE => self.handle_nc_mouse_move_msg(handle, lparam), @@ -170,6 +173,20 @@ impl WindowsWindowInner { } } + fn handle_end_session_msg(&self, wparam: WPARAM) -> Option { + if wparam.0 != 0 { + unsafe { + SendMessageW( + self.platform_window_handle, + WM_GPUI_END_SESSION, + Some(WPARAM(self.validation_number)), + None, + ); + } + } + Some(0) + } + fn handle_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { let origin = logical_point( lparam.signed_loword() as f32, diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 0c0f18a..9147ce5 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -79,7 +79,7 @@ pub(crate) struct WindowsPlatformState { #[derive(Default)] struct PlatformCallbacks { open_urls: Cell)>>>, - quit: Cell>>, + quit: Cell bool>>>, reopen: Cell>>, app_menu_action: Cell>>, will_open_app_menu: Cell>>, @@ -460,8 +460,12 @@ impl Platform for WindowsPlatform { } } - self.inner - .with_callback(|callbacks| &callbacks.quit, |callback| callback()); + self.inner.with_callback( + |callbacks| &callbacks.quit, + |callback| { + callback(); + }, + ); } fn quit(&self) { @@ -667,7 +671,7 @@ impl Platform for WindowsPlatform { .detach(); } - fn on_quit(&self, callback: Box) { + fn on_quit(&self, callback: Box bool>) { self.inner.state.callbacks.quit.set(Some(callback)); } @@ -997,7 +1001,8 @@ impl WindowsPlatformInner { | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD | WM_GPUI_DOCK_MENU_ACTION | WM_GPUI_KEYBOARD_LAYOUT_CHANGED - | WM_GPUI_GPU_DEVICE_LOST => self.handle_gpui_events(msg, wparam, lparam), + | WM_GPUI_GPU_DEVICE_LOST + | WM_GPUI_END_SESSION => self.handle_gpui_events(msg, wparam, lparam), WM_POWERBROADCAST => self.handle_power_broadcast(wparam), _ => None, }; @@ -1022,10 +1027,29 @@ impl WindowsPlatformInner { WM_GPUI_DOCK_MENU_ACTION => self.handle_dock_action_event(lparam.0 as _), WM_GPUI_KEYBOARD_LAYOUT_CHANGED => self.handle_keyboard_layout_change(), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), + WM_GPUI_END_SESSION => self.handle_end_session(), _ => unreachable!(), } } + fn handle_end_session(&self) -> Option { + let mut shutdown_completed = false; + self.with_callback( + |callbacks| &callbacks.quit, + |callback| shutdown_completed = callback(), + ); + log::logger().flush(); + if shutdown_completed { + std::process::exit(0); + } + + // Shutdown couldn't run synchronously, since the AppCell is already borrowed. + // Windows may terminate the application as soon as we return from this handler, but if we post a WM_QUIT message now, + // we may get to gracefully shut down the app before we're terminated by the OS. + unsafe { PostQuitMessage(0) }; + Some(0) + } + fn close_one_window(&self, target_window: HWND) -> bool { let Some(all_windows) = self.raw_window_handles.upgrade() else { log::error!("Failed to upgrade raw window handles"); From 5b461bbed56520099080749233006da1a1b323f5 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Mon, 24 Aug 2026 18:27:55 +0000 Subject: [PATCH 02/45] Prewarm Linux font match caches (#63158) Prewarming cosmic-text font cache completely eliminated expensive `get_font_matches` calls and thus reducing the time it takes to call `cosmic_text::shape::ShapeLine::new`. Release Notes: - Improved rendering performance on Linux by prewarming font match caches. --- crates/gpui/src/platform.rs | 2 + crates/gpui/src/text_system.rs | 16 +++ crates/gpui_wgpu/src/cosmic_text_system.rs | 122 +++++++++++++-------- 3 files changed, 96 insertions(+), 44 deletions(-) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index bdafe8c..16ca199 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1075,6 +1075,8 @@ pub trait PlatformTextSystem: Send + Sync { fn all_font_names(&self) -> Vec; /// Get the font ID for a font descriptor. fn font_id(&self, descriptor: &Font) -> Result; + /// Prewarm any system font caches needed to shape text. + fn prewarm_fonts(&self, _font_ids: &[FontId]) {} /// Get metrics for a font. fn font_metrics(&self, font_id: FontId) -> FontMetrics; /// Get typographic bounds for a glyph. diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index cfddc54..b68ee4b 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -166,6 +166,22 @@ impl TextSystem { ); } + /// Prewarm any system font caches needed to shape text. + /// + /// This may be expensive, so callers should generally invoke it on a + /// background executor. Missing entries are still populated on demand by + /// the normal shaping path. + pub fn prewarm_fonts(&self, fonts: &[Font]) { + let mut font_ids = SmallVec::<[FontId; 8]>::new(); + for font in fonts { + let font_id = self.resolve_font(font); + if !font_ids.contains(&font_id) { + font_ids.push(font_id); + } + } + self.platform_text_system.prewarm_fonts(&font_ids); + } + /// Get the bounding box for the given font and font size. /// A font's bounding box is the smallest rectangle that could enclose all glyphs /// in the font. superimposed over one another. diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index f8ffe77..eac883c 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -2,7 +2,8 @@ use anyhow::{Context as _, Ok, Result}; use collections::HashMap; use cosmic_text::{ Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont, - FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, Weight as CosmicWeight, + FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, Stretch, Style, + Weight as CosmicTextWeight, Weight as CosmicWeight, }; use gpui::{ Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, @@ -91,6 +92,27 @@ fn wght_instance( Some((CosmicWeight(value.round() as u16), coords)) } +struct FontMatchProperties { + primary_family_name: SharedString, + stretch: Stretch, + style: Style, + weight: CosmicTextWeight, + features: CosmicFontFeatures, + fallback_chain: Arc<[(FontId, SharedString)]>, +} + +impl FontMatchProperties { + fn attributes<'a>(&'a self, font_id: FontId, family_name: &'a str) -> Attrs<'a> { + Attrs::new() + .metadata(font_id.0) + .family(Family::Name(family_name)) + .stretch(self.stretch) + .style(self.style) + .weight(self.weight) + .font_features(self.features.clone()) + } +} + impl CosmicTextSystem { pub fn new(system_font_fallback: &str) -> Self { let font_system = FontSystem::new(); @@ -168,6 +190,10 @@ impl PlatformTextSystem for CosmicTextSystem { Ok(candidates[ix]) } + fn prewarm_fonts(&self, font_ids: &[FontId]) { + self.0.write().prewarm_fonts(font_ids); + } + fn font_metrics(&self, font_id: FontId) -> FontMetrics { let lock = self.0.read(); let loaded_font = lock.loaded_font(font_id); @@ -247,6 +273,43 @@ impl CosmicTextSystemState { &self.loaded_fonts[font_id.0] } + fn font_match_properties(&self, font_id: FontId) -> Option { + let loaded_font = self.loaded_font(font_id); + let Some(face) = self.font_system.db().face(loaded_font.font.id()) else { + log::warn!("font face not found in database for font_id {:?}", font_id); + return None; + }; + let Some(first_family) = face.families.first() else { + log::warn!("font face has no family names for font_id {:?}", font_id); + return None; + }; + + Some(FontMatchProperties { + primary_family_name: first_family.0.clone().into(), + stretch: face.stretch, + style: face.style, + weight: loaded_font.instantiated_weight, + features: loaded_font.features.clone(), + fallback_chain: Arc::clone(&loaded_font.user_fallback_chain), + }) + } + + fn prewarm_fonts(&mut self, font_ids: &[FontId]) { + for &font_id in font_ids { + let Some(properties) = self.font_match_properties(font_id) else { + continue; + }; + let primary_attributes = + properties.attributes(font_id, &properties.primary_family_name); + self.font_system.get_font_matches(&primary_attributes); + + for (fallback_id, fallback_name) in &*properties.fallback_chain { + let fallback_attributes = properties.attributes(*fallback_id, fallback_name); + self.font_system.get_font_matches(&fallback_attributes); + } + } + } + #[profiling::function] fn add_fonts(&mut self, fonts: Vec>) -> Result<()> { let db = self.font_system.db_mut(); @@ -653,55 +716,19 @@ impl CosmicTextSystemState { for run in font_runs { let run_end = offs + run.len; - let loaded_font = self.loaded_font(run.font_id); - let Some(face) = self.font_system.db().face(loaded_font.font.id()) else { - log::warn!( - "font face not found in database for font_id {:?}", - run.font_id - ); - offs = run_end; - continue; - }; - let Some(first_family) = face.families.first() else { - log::warn!( - "font face has no family names for font_id {:?}", - run.font_id - ); + let Some(properties) = self.font_match_properties(run.font_id) else { offs = run_end; continue; }; - let primary_family_name: SharedString = first_family.0.clone().into(); - let primary_stretch = face.stretch; - let primary_style = face.style; - // pinned instance weight - let primary_weight = loaded_font.instantiated_weight; - let primary_features = loaded_font.features.clone(); - let fallback_chain = Arc::clone(&loaded_font.user_fallback_chain); - - // build one `Attrs` per slot up front. each clone of span attrs - // would otherwise re-allocate the `font_features` Vec. - let primary_attrs = Attrs::new() - .metadata(run.font_id.0) - .family(Family::Name(&primary_family_name)) - .stretch(primary_stretch) - .style(primary_style) - .weight(primary_weight) - .font_features(primary_features.clone()); - let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = fallback_chain + let primary_attrs = properties.attributes(run.font_id, &properties.primary_family_name); + let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = properties + .fallback_chain .iter() - .map(|(fb_id, fb_name)| { - Attrs::new() - .metadata(fb_id.0) - .family(Family::Name(fb_name)) - .stretch(primary_stretch) - .style(primary_style) - .weight(primary_weight) - .font_features(primary_features.clone()) - }) + .map(|(font_id, family_name)| properties.attributes(*font_id, family_name)) .collect(); - let spans = if fallback_chain.is_empty() { + let spans = if properties.fallback_chain.is_empty() { let mut spans = SmallVec::<[RunSpan; 4]>::new(); spans.push(RunSpan { start: offs, @@ -713,7 +740,14 @@ impl CosmicTextSystemState { } else { let loaded_fonts = &self.loaded_fonts; let covers = |id: FontId, ch: char| charmap_covers(loaded_fonts, id, ch); - compute_run_spans(text, offs, run.len, run.font_id, &fallback_chain, &covers) + compute_run_spans( + text, + offs, + run.len, + run.font_id, + &properties.fallback_chain, + &covers, + ) }; for span in spans { From 843283c7ed6e7735b0af2d868e5fad65eef30c20 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 25 Aug 2026 08:12:04 +0000 Subject: [PATCH 03/45] gpui: Make stacker opt-in (#63187) Allow disabling stacker in gpui (as on wasm it uncondtionally allocates more stack) Release Notes: - N/A --- crates/gpui/Cargo.toml | 3 ++- crates/gpui/src/elements/div.rs | 17 ++++++++++++----- crates/gpui/src/taffy.rs | 31 +++++++++++++------------------ 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 96905d0..46126fb 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -38,6 +38,7 @@ screen-capture = [ ] windows-manifest = ["dep:embed-resource"] profiler = ["dep:hdrhistogram"] +stacker = ["dep:stacksafe"] [lib] path = "src/gpui.rs" @@ -87,7 +88,7 @@ serde_json.workspace = true slotmap.workspace = true smallvec.workspace = true async-channel.workspace = true -stacksafe.workspace = true +stacksafe = { workspace = true, optional = true } strum.workspace = true sum_tree.workspace = true taffy = "=0.13.0" diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 2d1b0bf..a312e86 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -30,7 +30,6 @@ use collections::HashMap; use gpui_util::ResultExt; use refineable::Refineable; use smallvec::SmallVec; -use stacksafe::{StackSafe, stacksafe}; use std::{ any::{Any, TypeId}, cell::RefCell, @@ -45,6 +44,11 @@ use std::{ use super::ImageCacheProvider; +#[cfg(feature = "stacker")] +type StackSafe = stacksafe::StackSafe; +#[cfg(not(feature = "stacker"))] +type StackSafe = T; + const DRAG_THRESHOLD: f64 = 2.; const DEFAULT_TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500); const HOVERABLE_TOOLTIP_HIDE_DELAY: Duration = Duration::from_millis(500); @@ -1777,8 +1781,11 @@ impl InteractiveElement for Div { impl ParentElement for Div { fn extend(&mut self, elements: impl IntoIterator) { + #[cfg(feature = "stacker")] self.children - .extend(elements.into_iter().map(StackSafe::new)) + .extend(elements.into_iter().map(StackSafe::new)); + #[cfg(not(feature = "stacker"))] + self.children.extend(elements); } } @@ -1816,7 +1823,7 @@ impl Element for Div { } } - #[stacksafe] + #[cfg_attr(feature = "stacker", stacksafe::stacksafe)] fn request_layout( &mut self, global_id: Option<&GlobalElementId>, @@ -1852,7 +1859,7 @@ impl Element for Div { (layout_id, DivFrameState { child_layout_ids }) } - #[stacksafe] + #[cfg_attr(feature = "stacker", stacksafe::stacksafe)] fn prepaint( &mut self, global_id: Option<&GlobalElementId>, @@ -1947,7 +1954,7 @@ impl Element for Div { ) } - #[stacksafe] + #[cfg_attr(feature = "stacker", stacksafe::stacksafe)] fn paint( &mut self, global_id: Option<&GlobalElementId>, diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 13d3bc4..b9e583a 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -7,7 +7,6 @@ use crate::{ }, }; use collections::{FxHashMap, FxHashSet}; -use stacksafe::{StackSafe, stacksafe}; use std::{fmt::Debug, ops::Range}; use taffy::{ TaffyTree, TraversePartialTree as _, @@ -17,16 +16,14 @@ use taffy::{ tree::NodeId, }; -type NodeMeasureFn = StackSafe< - Box< - dyn FnMut( - Size>, - Size, - &mut Window, - &mut App, - ) -> Size, - >, ->; +#[cfg(feature = "stacker")] +type StackSafe = stacksafe::StackSafe; +#[cfg(not(feature = "stacker"))] +type StackSafe = T; + +type MeasureFn = + dyn FnMut(Size>, Size, &mut Window, &mut App) -> Size; +type NodeMeasureFn = StackSafe>; struct NodeContext { measure: NodeMeasureFn, @@ -99,14 +96,12 @@ impl TaffyLayoutEngine { + 'static, ) -> LayoutId { let taffy_style = style.to_taffy(rem_size, scale_factor); + let measure = Box::new(measure) as Box; + #[cfg(feature = "stacker")] + let measure = StackSafe::new(measure); self.taffy - .new_leaf_with_context( - taffy_style, - NodeContext { - measure: StackSafe::new(Box::new(measure)), - }, - ) + .new_leaf_with_context(taffy_style, NodeContext { measure }) .expect(EXPECT_MESSAGE) .into() } @@ -188,7 +183,7 @@ impl TaffyLayoutEngine { Ok(edges) } - #[stacksafe] + #[cfg_attr(feature = "stacker", stacksafe::stacksafe)] pub fn compute_layout( &mut self, id: LayoutId, From 0342ddd91ff0623766bb519c29230c196a22baac Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 25 Aug 2026 17:56:09 +0000 Subject: [PATCH 04/45] gpui_wgpu: Reuse glyph image after measuring bounds (#63209) Release Notes: - Improved rasterization performance on Linux by caching glyphs after measuring bounds. --- crates/gpui_wgpu/src/cosmic_text_system.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index eac883c..79bb5bc 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -7,7 +7,7 @@ use cosmic_text::{ }; use gpui::{ Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, - LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, + IsZero as _, LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, size, }; @@ -54,6 +54,7 @@ struct CosmicTextSystemState { font_system: FontSystem, scratch: ShapeBuffer, swash_scale_context: ScaleContext, + pending_glyph_images: HashMap, /// Contains all already loaded fonts, including all faces. Indexed by `FontId`. loaded_fonts: Vec, /// Caches the `FontId`s associated with a specific family to avoid iterating the font database @@ -121,6 +122,7 @@ impl CosmicTextSystem { font_system, scratch: ShapeBuffer::default(), swash_scale_context: ScaleContext::new(), + pending_glyph_images: HashMap::default(), loaded_fonts: Vec::new(), font_ids_by_family_cache: HashMap::default(), system_font_fallback: system_font_fallback.to_string(), @@ -137,6 +139,7 @@ impl CosmicTextSystem { font_system, scratch: ShapeBuffer::default(), swash_scale_context: ScaleContext::new(), + pending_glyph_images: HashMap::default(), loaded_fonts: Vec::new(), font_ids_by_family_cache: HashMap::default(), system_font_fallback: system_font_fallback.to_string(), @@ -462,10 +465,14 @@ impl CosmicTextSystemState { fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result> { let image = self.render_glyph_image(params)?; - Ok(Bounds { + let bounds = Bounds { origin: point(image.placement.left.into(), (-image.placement.top).into()), size: size(image.placement.width.into(), image.placement.height.into()), - }) + }; + if !bounds.is_zero() { + self.pending_glyph_images.insert(params.clone(), image); + } + Ok(bounds) } #[profiling::function] @@ -478,7 +485,10 @@ impl CosmicTextSystemState { anyhow::bail!("glyph bounds are empty"); } - let mut image = self.render_glyph_image(params)?; + let mut image = match self.pending_glyph_images.remove(params) { + Some(image) => image, + None => self.render_glyph_image(params)?, + }; let bitmap_size = glyph_bounds.size; match image.content { swash::scale::image::Content::Color | swash::scale::image::Content::SubpixelMask => { From e61b9260938eac9d33553011e31e0dd9945a8735 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:21:19 +0000 Subject: [PATCH 05/45] gpui: Report average dirty-to-present frame timings (#63221) Extends the existing GPUI frame-duration telemetry with the average time from a window's first invalidation through presentation. - Record dirty-to-present durations in each window profiler - Include the declared root entity type name on window handles - Add the average and root type to the existing five-minute `Frame Duration Report` event Testing: - `cargo fmt --all -- --check` - `cargo test -p gpui --features profiler records_dirty_to_present_durations` - `cargo check -p gpui --no-default-features` - `cargo check -p input_latency_ui -p zed` Release Notes: - N/A --- crates/gpui/src/profiler.rs | 33 ++++++++++++++++++++++++++++++++- crates/gpui/src/window.rs | 7 +++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index 1c4f09a..f3dc0ce 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -852,6 +852,8 @@ pub enum FrameEvent { #[cfg(feature = "profiler")] #[derive(Clone)] pub struct FrameDurationSnapshot { + /// Histogram of durations from the first invalidation through presentation, in nanoseconds. + pub dirty_to_present_histogram: Histogram, /// Histogram of `Window::draw` durations, in nanoseconds. pub draw_duration_histogram: Histogram, /// Histogram of intervals between consecutively presented frames while the @@ -894,6 +896,7 @@ pub struct WindowProfiler { window_id: WindowId, active_activities: SmallVec<[WindowActivity; 4]>, active_actions: SmallVec<[(&'static str, Instant); 2]>, + dirty_to_present_histogram: Histogram, draw_duration_histogram: Histogram, present_interval_histogram: Histogram, first_input_at: Option, @@ -914,6 +917,9 @@ impl WindowProfiler { window_id, active_activities: SmallVec::new(), active_actions: SmallVec::new(), + dirty_to_present_histogram: Histogram::new(3).map_err(|error| { + anyhow::anyhow!("Failed to create dirty-to-present histogram: {error}") + })?, draw_duration_histogram: Histogram::new(3).map_err(|error| { anyhow::anyhow!("Failed to create draw duration histogram: {error}") })?, @@ -1070,6 +1076,7 @@ impl WindowProfiler { /// Returns a snapshot of the current frame-duration histograms. pub fn frame_duration_snapshot(&self) -> FrameDurationSnapshot { FrameDurationSnapshot { + dirty_to_present_histogram: self.dirty_to_present_histogram.clone(), draw_duration_histogram: self.draw_duration_histogram.clone(), present_interval_histogram: self.present_interval_histogram.clone(), } @@ -1109,8 +1116,16 @@ impl WindowProfiler { }; journal::record_present(present_timing, frame); - if frame.is_none() { + let Some(frame) = frame else { return; + }; + + if let Some(dirty_at) = frame.dirty_at + && let Err(error) = self + .dirty_to_present_histogram + .record(present_end.duration_since(dirty_at).as_nanos() as u64) + { + log::error!("failed to record dirty-to-present frame timing: {error}"); } if let Some(animation_interval) = animation_interval { @@ -1408,6 +1423,22 @@ mod tests { assert_eq!(window_profiler.present_interval_histogram.len(), 1); } + #[test] + fn records_dirty_to_present_durations() { + let mut window_profiler = + WindowProfiler::new(WindowId::from(8)).expect("window profiler should initialize"); + let draw_end = Instant::now(); + let present_end = draw_end + Duration::from_millis(6); + + record_test_draw(&mut window_profiler, draw_end); + window_profiler.record_present_at(present_end, present_end, true, false); + + let snapshot = window_profiler.frame_duration_snapshot(); + let histogram = snapshot.dirty_to_present_histogram; + assert_eq!(histogram.len(), 1); + assert!(histogram.max() >= Duration::from_millis(10).as_nanos() as u64); + } + #[cfg(feature = "profiler")] #[test] fn records_every_draw_duration() { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2f7b589..976eadd 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -6640,6 +6640,7 @@ impl WindowHandle { any_handle: AnyWindowHandle { id, state_type: TypeId::of::(), + root_entity_type_name: std::any::type_name::(), }, state_type: PhantomData, } @@ -6762,6 +6763,7 @@ impl From> for AnyWindowHandle { pub struct AnyWindowHandle { pub(crate) id: WindowId, state_type: TypeId, + root_entity_type_name: &'static str, } impl AnyWindowHandle { @@ -6770,6 +6772,11 @@ impl AnyWindowHandle { self.id } + /// Returns the name of the window's declared root entity type. + pub fn root_entity_type_name(&self) -> &'static str { + self.root_entity_type_name + } + /// Attempt to convert this handle to a window handle with a specific root view type. /// If the types do not match, this will return `None`. pub fn downcast(&self) -> Option> { From dbe9a37068f25e6bcf507c889e7c70d7df0b9acd Mon Sep 17 00:00:00 2001 From: Oscar Date: Wed, 26 Aug 2026 07:46:31 +0000 Subject: [PATCH 06/45] gpui: Use platform clock in action profiler (#63183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective I’m building a component library with GPUI and ran into a panic when I enabled the profiler feature for a `wasm32-unknown-unknown` build. Dispatching an action calls std::time::Instant::now(), which has no clock implementation on this target, and the browser reports time not implemented on this platform. The panic then triggers RefCell already borrowed errors and leaves the GPUI window unresponsive. The current `main` fails to compile this feature combination because the action profiler’s std::time::Instant does not match the scheduler-based timestamps in the profiler journal. I want GPUI’s action profiler to use the same cross-platform clock as the rest of the profiler and keep this configuration covered by CI. ## Solution Use `scheduler::Instant`, the scheduler crate's cross-platform instant type, for action timings so they match the rest of the GPUI profiler. Keep the existing downstream wasm check and add a separate profiler-enabled GPUI wasm check so CI covers both feature configurations. ## Testing - `cargo check --target wasm32-unknown-unknown -p gpui --no-default-features --features profiler` - `cargo -Zbuild-std=std,panic_abort check --target wasm32-unknown-unknown -p gpui_platform -p cloud_api_client --features gpui/profiler` - `cargo test -p gpui --no-default-features --features profiler profiler::` (48 tests passed) - `./script/clippy -p gpui` I ran a wasm GPUI app in Edge with WebGPU. The app opens a window and dispatches an action with `profiler` enabled. Before this change, the action produced the clock panic and follow-on borrow errors. After the fix change, the action completed and the window remained active. I did not run the browser harness in Firefox or Safari, though the wasm compile check covers the changed profiler configuration without depending on a browser. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines); this PR has no UI changes - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- crates/gpui/src/profiler/actions.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/profiler/actions.rs b/crates/gpui/src/profiler/actions.rs index be82af2..a4892f8 100644 --- a/crates/gpui/src/profiler/actions.rs +++ b/crates/gpui/src/profiler/actions.rs @@ -1,6 +1,7 @@ -use std::time::{Duration, Instant}; +use std::time::Duration; use itertools::Itertools; +use scheduler::Instant; #[cfg(feature = "profiler")] use crate::action::Action; From d507d4fce2396f9a5dcc2a2cce8c524886b7efe7 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 26 Aug 2026 07:49:12 +0000 Subject: [PATCH 07/45] gpui_windows: Clear render target before compositing COLR emoji (#63225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … layers # Objective olor emoji (COLR glyphs) are rasterized on the GPU by compositing each glyph layer into a D3D11 render target that is created with `pInitialData: None` and, until now, never cleared. The texture contents are undefined: texels not covered by any layer quad retained garbage (typically leftovers from previous rasterizations via the DXGI allocation pool). ## Solution - Clear the render target with `[0, 0, 0, 0]` (premultiplied transparent) immediately after binding it and before drawing the layers. ## Testing add test to test this specifically ## Showcase before : ezgif-1a339c7dea79f905 after : Screenshot 2026-08-26 015248 --- crates/gpui_windows/src/direct_write.rs | 231 +++++++++++++++++++++--- 1 file changed, 210 insertions(+), 21 deletions(-) diff --git a/crates/gpui_windows/src/direct_write.rs b/crates/gpui_windows/src/direct_write.rs index 08ae7a9..5a761cc 100644 --- a/crates/gpui_windows/src/direct_write.rs +++ b/crates/gpui_windows/src/direct_write.rs @@ -998,24 +998,6 @@ impl DirectWriteState { } let gpu_state = &self.gpu_state; - let params_buffer = { - let desc = D3D11_BUFFER_DESC { - ByteWidth: std::mem::size_of::() as u32, - Usage: D3D11_USAGE_DYNAMIC, - BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, - CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, - MiscFlags: 0, - StructureByteStride: 0, - }; - - let mut buffer = None; - unsafe { - gpu_state - .device - .CreateBuffer(&desc, None, Some(&mut buffer)) - }?; - buffer - }; let render_target_texture = { let mut texture = None; @@ -1061,6 +1043,41 @@ impl DirectWriteState { rtv }; + Self::composite_color_layers( + gpu_state, + &glyph_layers, + bitmap_size, + &render_target_texture, + &render_target_view, + ) + } + + fn composite_color_layers( + gpu_state: &GPUState, + glyph_layers: &[GlyphLayerTexture], + bitmap_size: Size, + render_target_texture: &ID3D11Texture2D, + render_target_view: &Option, + ) -> Result> { + let params_buffer = { + let desc = D3D11_BUFFER_DESC { + ByteWidth: std::mem::size_of::() as u32, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + MiscFlags: 0, + StructureByteStride: 0, + }; + + let mut buffer = None; + unsafe { + gpu_state + .device + .CreateBuffer(&desc, None, Some(&mut buffer)) + }?; + buffer + }; + let staging_texture = { let mut texture = None; let desc = D3D11_TEXTURE2D_DESC { @@ -1097,8 +1114,13 @@ impl DirectWriteState { device_context.PSSetConstantBuffers(0, Some(std::slice::from_ref(¶ms_buffer))) }; unsafe { - device_context.OMSetRenderTargets(Some(std::slice::from_ref(&render_target_view)), None) + device_context.OMSetRenderTargets(Some(std::slice::from_ref(render_target_view)), None) }; + unsafe { + if let Some(render_target_view) = render_target_view.as_ref() { + device_context.ClearRenderTargetView(render_target_view, &[0.0, 0.0, 0.0, 0.0]); + } + } unsafe { device_context.PSSetSamplers(0, Some(std::slice::from_ref(&gpu_state.sampler))) }; unsafe { device_context.OMSetBlendState(&gpu_state.blend_state, None, 0xffffffff) }; @@ -1131,7 +1153,7 @@ impl DirectWriteState { .Unmap(params_buffer.as_ref().unwrap(), 0); }; - let texture = [Some(layer.texture_view)]; + let texture = [Some(layer.texture_view.clone())]; unsafe { device_context.PSSetShaderResources(0, Some(&texture)) }; let viewport = [D3D11_VIEWPORT { @@ -1147,7 +1169,7 @@ impl DirectWriteState { unsafe { device_context.Draw(4, 0) }; } - unsafe { device_context.CopyResource(&staging_texture, &render_target_texture) }; + unsafe { device_context.CopyResource(&staging_texture, render_target_texture) }; let mapped_data = { let mut mapped_data = D3D11_MAPPED_SUBRESOURCE::default(); @@ -1931,7 +1953,20 @@ const DEFAULT_LOCALE_NAME: PCWSTR = windows::core::w!("en-US"); #[cfg(test)] mod tests { + use super::{DirectWriteState, DirectWriteTextSystem, GPUState, GlyphLayerTexture}; use crate::direct_write::ClusterAnalyzer; + use crate::directx_devices::DirectXDevices; + use anyhow::Result; + use gpui::{ + DevicePixels, Font, PlatformTextSystem, RenderGlyphParams, Rgba, bounds, point, px, size, + }; + use std::ffi::c_void; + use windows::Win32::Graphics::Direct3D11::{ + D3D11_BIND_RENDER_TARGET, D3D11_RENDER_TARGET_VIEW_DESC, D3D11_RENDER_TARGET_VIEW_DESC_0, + D3D11_RTV_DIMENSION_TEXTURE2D, D3D11_SUBRESOURCE_DATA, D3D11_TEX2D_RTV, + D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, + }; + use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC}; #[test] fn test_cluster_map() { @@ -1969,4 +2004,158 @@ mod tests { let next = analyzer.next(); assert_eq!(next, None); } + + #[test] + fn color_emoji_rasterization_is_stable_across_batches() -> Result<()> { + let devices = DirectXDevices::new()?; + let text_system = DirectWriteTextSystem::new(&devices)?; + + let font = Font { + family: "Segoe UI Emoji".into(), + ..Default::default() + }; + let font_id = text_system.font_id(&font)?; + + let mut params_list = Vec::new(); + for ch in ['🫠', 'πŸ₯Ή', 'πŸ§—', 'πŸ‹', 'πŸš€', 'πŸ₯Ί'] { + let Some(glyph_id) = text_system.glyph_for_char(font_id, ch) else { + log::info!("no glyph found for {ch}"); + continue; + }; + let params = RenderGlyphParams { + font_id, + glyph_id, + font_size: px(48.0), + subpixel_variant: point(0u8, 0u8), + scale_factor: 1.0, + is_emoji: true, + subpixel_rendering: false, + dilation: 0, + }; + let raster_bounds = text_system.glyph_raster_bounds(¶ms)?; + if raster_bounds.size.width.0 == 0 || raster_bounds.size.height.0 == 0 { + log::info!("raster bounds are empty for {ch}"); + continue; + } + params_list.push((params, raster_bounds)); + } + assert!(!params_list.is_empty()); + + let first: Vec<_> = params_list + .iter() + .map(|(params, bounds)| text_system.rasterize_glyph(params, *bounds)) + .collect::>()?; + + // Churn the texture heap with further rasterization passes. If the color + // compositing leaks leftover texture data (the render target is not cleared), + // the second batch can pick up different contents and differ from the first. + // With an explicit clear both batches are deterministic and identical. + for _ in 0..3 { + for (params, bounds) in ¶ms_list { + text_system.rasterize_glyph(params, *bounds)?; + } + } + let second: Vec<_> = params_list + .iter() + .map(|(params, bounds)| text_system.rasterize_glyph(params, *bounds)) + .collect::>()?; + + assert_eq!( + first, second, + "color glyph rasterization changed between batches; \ + render target contents are leaking into the glyph bitmaps" + ); + Ok(()) + } + + #[test] + fn color_emoji_composites_over_cleared_texture() -> Result<()> { + let devices = DirectXDevices::new()?; + let gpu_state = GPUState::new(&devices)?; + + const SIZE: u32 = 32; + // Seed the render target with solid red so that any texel which the + // compositing pass fails to clear/overwrite remains identifiable after + // the readback. + let poison = { + let mut v = vec![0u8; (SIZE * SIZE * 4) as usize]; + for pixel in v.chunks_exact_mut(4) { + pixel[2] = 255; + pixel[3] = 255; + } + v + }; + let desc = D3D11_TEXTURE2D_DESC { + Width: SIZE, + Height: SIZE, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let initial_data = D3D11_SUBRESOURCE_DATA { + pSysMem: poison.as_ptr() as *const c_void, + SysMemPitch: SIZE * 4, + SysMemSlicePitch: 0, + }; + let texture = unsafe { + let mut texture = None; + gpu_state + .device + .CreateTexture2D(&desc, Some(&initial_data), Some(&mut texture))?; + texture.unwrap() + }; + let render_target_view = unsafe { + let desc = D3D11_RENDER_TARGET_VIEW_DESC { + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D, + Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 { + Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 }, + }, + }; + let mut rtv = None; + gpu_state + .device + .CreateRenderTargetView(&texture, Some(&desc), Some(&mut rtv))?; + rtv.unwrap() + }; + + // A single opaque layer in the top-left corner; the bottom-right corner + // of the texture is covered by no layer at all. + let layer_alpha = vec![255u8; 4 * 4]; + let layer = GlyphLayerTexture::new( + &gpu_state, + Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }, + bounds(point(0, 0), size(4, 4)), + &layer_alpha, + )?; + + let rasterized = DirectWriteState::composite_color_layers( + &gpu_state, + std::slice::from_ref(&layer), + size(DevicePixels(SIZE as i32), DevicePixels(SIZE as i32)), + &texture, + &Some(render_target_view), + )?; + + let corner = (SIZE as usize - 1 + (SIZE as usize - 1) * SIZE as usize) * 4; + assert_eq!( + &rasterized[corner..corner + 4], + &[0, 0, 0, 0], + "uncovered texel retained the poison from the uninitialized render target" + ); + Ok(()) + } } From 2281886f6d6966393ce1bf091ab98e2754cc6a0c Mon Sep 17 00:00:00 2001 From: david lee Date: Wed, 26 Aug 2026 08:15:17 +0000 Subject: [PATCH 08/45] Fix missing NUL terminator in X11 WM_CLASS (#62848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Objective GPUI's X11 `WM_CLASS` property omits its final NUL terminator, so any window manager that relies on the terminator reads the class one byte short. `WM_CLASS` is specified by [ICCCM Β§4.1.2.5](https://tronche.com/gui/x/icccm/sec-4.html#s-4.1.2.5) as two consecutive NUL-**terminated** strings, `instance\0class\0`. `set_app_id` writes the separator but not the final terminator: ```rust let mut data = Vec::with_capacity(app_id.len() * 2 + 1); data.extend(app_id.bytes()); // instance data.push(b'\0'); data.extend(app_id.bytes()); // class <- no trailing NUL ``` This affects Zed itself. `ReleaseChannel::app_id()` returns `dev.zed.Zed` on stable, so under X11 and XWayland the class is read as `dev.zed.Ze`. The shipped desktop file is `dev.zed.Zed.desktop`, so the association it depends on is exactly the string being truncated, and window-manager rules or taskbar grouping matching `dev.zed.Zed` silently fail to apply. Wayland is unaffected, since the app id is set through `xdg_toplevel` rather than an X11 property. ## Solution Append the missing terminator after the class string, and size the `Vec` capacity to match (`* 2 + 2` rather than `* 2 + 1`). ## Testing Tested manually on Arch Linux with Hyprland v0.56.2 (wlroots) via XWayland. **Reproducing the bug** β€” Zed 1.14.2, before the fix: ``` $ env -u WAYLAND_DISPLAY zeditor some-dir $ hyprctl clients -j | jq '.[] | select(.xwayland) | .class' "dev.zed.Zed\u0000dev.zed.Ze" ``` The same window read with `xprop`, which does not require the terminator, shows the intended value β€” confirming the property itself is malformed rather than the compositor misreading it: ``` $ xprop -id 0x800001 WM_CLASS WM_CLASS(STRING) = "dev.zed.Zed", "dev.zed.Zed" ``` **Control** β€” on the same compositor and the same XWayland path, Alacritty (winit, which writes the trailing NUL) reports its class intact: ``` $ hyprctl clients -j | jq '.[] | select(.xwayland) | .class' "Alacritty" ``` The same truncation was reproduced independently with a third-party GPUI application using `app_id: Some("sprite")`, which reports `sprite\u0000sprit`. **Platforms:** verified on Linux/XWayland only. Wayland does not use this code path. macOS and Windows have their own `set_app_id` implementations and are unaffected by this change. **Needs more testing:** confirmation on a bare X11 session (not XWayland) and on a non-wlroots window manager such as i3, KWin, or Mutter would be welcome β€” the malformed property is compositor-independent, but which readers visibly truncate it is not. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments β€” *none added* - [x] The content adheres to Zed's UI standards β€” *N/A, no UI change* - [ ] Tests cover the new/changed behavior β€” *see note below* - [x] Performance impact has been considered and is acceptable β€” *one additional byte in a property set once per window* "No automated test: asserting the property bytes requires a live X server. Happy to add one if there's an existing pattern for X11 window tests I've missed." Co-authored-by: Lukas Wirth --- crates/gpui_linux/src/linux/x11/window.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index f9423a3..d6db24e 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1568,10 +1568,11 @@ impl PlatformWindow for X11Window { } fn set_app_id(&mut self, app_id: &str) { - let mut data = Vec::with_capacity(app_id.len() * 2 + 1); + let mut data = Vec::with_capacity(app_id.len() * 2 + 2); data.extend(app_id.bytes()); // instance https://unix.stackexchange.com/a/494170 data.push(b'\0'); data.extend(app_id.bytes()); // class + data.push(b'\0'); check_reply( || "X11 ChangeProperty8 for WM_CLASS failed.", From 190b0d14ae948126daa18cbb7772cb84a6e6f816 Mon Sep 17 00:00:00 2001 From: JKN Date: Wed, 26 Aug 2026 08:17:09 +0000 Subject: [PATCH 09/45] gpui_windows: Implement render_to_image for headless window capture (#63012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective `gpui_macos` implements `PlatformWindow::render_to_image` β€” the MetalRenderer samples an offscreen target, so a **hidden** window can be captured. `gpui_windows` does not implement it, so the method falls through to the trait default and returns `Err("render_to_image not implemented for this platform")`. That leaves Windows consumers (tests, headless screenshot harnesses) on `PrintWindow`-style workarounds, which require the window to actually be on screen: a DirectComposition swap chain that never presents has no frame to read, so you get a visible flash β€” or an off-screen parking trick β€” for every capture. This closes the macOS/Windows gap. Linux (`gpui_linux`) still returns the trait default; a Blade/wgpu readback is the obvious follow-up but is not in this PR. ## Solution The DirectX analogue of the Metal path: - `DirectXRenderer::draw`'s clear, scene upload and batch encoding are factored into a shared `render(scene, background_appearance)`. `draw` is now `render` + `present`; `render_to_image` is `render` + readback. Nothing about how a frame is produced is duplicated between them, so the two cannot drift. - `render_to_image` renders into the **existing** render target β€” created at window construction, so no window need ever be shown β€” then copies it into a `D3D11_USAGE_STAGING` texture, `Map`s it, and converts BGRA β†’ RGBA. `RowPitch` padding is honoured per row; the copy loop carries a safety comment. - It refuses to run while `skip_draws` is set. That flag marks a pending device-lost recovery, where the atlas still holds tile references from the previous device β€” drawing before the forced re-render rebuilds them panics in `DirectXAtlasState::texture` (the case `WindowState::force_render_pending` documents). Returning an error beats panicking in a capture harness. - Gated on `cfg(any(test, feature = "test-support"))` to match the trait method. `gpui_platform`'s `test-support` now forwards to `gpui_windows/test-support`, the way it already does for `gpui_macos`. No behaviour change outside `test-support`/`cfg(test)` builds beyond the `render` factoring, which is a pure code move. ## Testing - **Windows, functional:** an earlier revision of this patch has been in production use since June in the `--screenshot` harness of a GPUI app I ship ([Ferail](https://github.com/jonx/Ferail)), capturing a hidden window (`show: false`) with no flash. BGRAβ†’RGBA output was verified against the on-screen rendering. - **This exact revision** has been cross-checked from macOS only: `cargo clippy -p gpui_windows --no-default-features --features test-support --target x86_64-pc-windows-msvc -- -D warnings` is clean, both with and without `test-support` (clang-cl + `xwin`; `--no-default-features` skips only the `windows-manifest` embed-resource step, which is irrelevant here). I'm relying on CI for a native Windows build of this revision β€” happy to report back from a Windows host if that's a blocker for review. - **Not covered by an automated test.** The behaviour needs a live D3D11 device, so it can only be exercised by a Windows-hosted test. Happy to add one under `gpui_windows` if you'd like it β€” say the word and I'll push it rather than guess at the shape you want. - **Reviewers:** the interesting part is the staging-texture readback in `directx_renderer.rs`. Worth a second pair of eyes on the `skip_draws` guard and on premultiplied alpha (the swap chain is `DXGI_ALPHA_MODE_PREMULTIPLIED`, so non-opaque captures come back premultiplied β€” same as the macOS path, but it does mean semi-transparent regions are not straight-alpha). ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) β€” n/a, no UI surface - [ ] Tests cover the new/changed behavior β€” see Testing; needs a Windows host, happy to add - [x] Performance impact has been considered and is acceptable β€” the readback path is `test-support`-only; `draw` is unchanged apart from the `render` factoring ## Showcase --- crates/gpui_windows/src/directx_renderer.rs | 96 ++++++++++++++++++++- crates/gpui_windows/src/window.rs | 8 ++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 681e390..6e83043 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -795,11 +795,24 @@ impl DirectXRenderer { // and so likely do not have the textures anymore that are required for drawing return Ok(()); } + self.render(scene, background_appearance)?; + self.present() + } + + /// Clear the render target for `background_appearance` and encode every + /// primitive batch of `scene` into it, without presenting. Shared by + /// [`draw`](Self::draw) (which then presents) and + /// [`render_to_image`](Self::render_to_image) (which reads the target back + /// instead), so the two cannot drift. + fn render( + &mut self, + scene: &Scene, + background_appearance: WindowBackgroundAppearance, + ) -> Result<()> { self.pre_draw(&match background_appearance { WindowBackgroundAppearance::Opaque => [1.0f32; 4], _ => [0.0f32; 4], })?; - self.upload_scene_buffers(scene)?; // Backdrops read what the frame has drawn so far, which the swap chain cannot be sampled @@ -930,7 +943,6 @@ impl DirectXRenderer { ) })?; } - while let Some(index) = stack.pop() { let layer = scene.effects[index]; let clip = spans.pop().unwrap_or_else(|| layer.destination_clip()); @@ -941,7 +953,85 @@ impl DirectXRenderer { self.blit_frame()?; } - self.present() + Ok(()) + } + + /// Render `scene` to an offscreen CPU image **without presenting** so + /// the window need never be shown or visible (the macOS headless path + /// goes through MetalRenderer; this is the Windows analogue). Draws into + /// the existing render target, copies it into a `D3D11_USAGE_STAGING` + /// texture, maps it, and converts BGRA to RGBA. + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn render_to_image( + &mut self, + scene: &Scene, + background_appearance: WindowBackgroundAppearance, + ) -> Result { + // A pending device-lost recovery (`skip_draws`) leaves the atlas holding + // tile references from the previous device; drawing before the forced + // re-render rebuilds them panics in `DirectXAtlasState::texture`. + anyhow::ensure!( + !self.skip_draws, + "render_to_image unavailable while recovering from a lost device" + ); + self.render(scene, background_appearance)?; + + let devices = self.devices.as_ref().context("devices missing")?; + let device = &devices.device; + let context = &devices.device_context; + let resources = self.resources.as_ref().context("resources missing")?; + let render_target = resources + .render_target + .as_ref() + .context("render target missing")?; + + // A CPU-readable copy of the render target. + let mut desc = D3D11_TEXTURE2D_DESC::default(); + unsafe { render_target.GetDesc(&mut desc) }; + let width = desc.Width; + let height = desc.Height; + let staging_desc = D3D11_TEXTURE2D_DESC { + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + MipLevels: 1, + ArraySize: 1, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + ..desc + }; + let mut staging: Option = None; + unsafe { device.CreateTexture2D(&staging_desc, None, Some(&mut staging))? }; + let staging = staging.context("creating staging texture")?; + unsafe { context.CopyResource(&staging, render_target) }; + + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + unsafe { context.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))? }; + let row_bytes = (width as usize) * 4; + let mut pixels = vec![0u8; row_bytes * height as usize]; + // SAFETY: `Map` succeeded, so `pData` points at `RowPitch * height` + // readable bytes for as long as the mapping is held, and `RowPitch >= + // row_bytes` (it only ever adds trailing padding). `pixels` is sized + // `row_bytes * height`, so every copy stays in bounds on both sides, + // and the regions cannot overlap (`pixels` is a fresh allocation). + unsafe { + let src = mapped.pData as *const u8; + for row in 0..height as usize { + let s = src.add(row * mapped.RowPitch as usize); + let d = pixels.as_mut_ptr().add(row * row_bytes); + std::ptr::copy_nonoverlapping(s, d, row_bytes); + } + context.Unmap(&staging, 0); + } + // The render target is BGRA; image::RgbaImage expects RGBA. + for px in pixels.chunks_exact_mut(4) { + px.swap(0, 2); + } + image::RgbaImage::from_raw(width, height, pixels) + .context("Failed to build RgbaImage from staging readback") } pub(crate) fn resize(&mut self, new_size: Size) -> Result<()> { diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index af24071..d81efac 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -996,6 +996,14 @@ impl PlatformWindow for WindowsWindow { .log_err(); } + #[cfg(any(test, feature = "test-support"))] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + self.state + .renderer + .borrow_mut() + .render_to_image(scene, self.state.background_appearance.get()) + } + fn sprite_atlas(&self) -> Arc { self.state.renderer.borrow().sprite_atlas() } From 95db0df4e5c608848fc699dad072808bd5fb50f3 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Wed, 26 Aug 2026 08:19:39 +0000 Subject: [PATCH 10/45] gpui: Don't start a wrapped line with closing punctuation or a slash (#62743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective `LineWrapper` treats every punctuation character as a break opportunity, so a wrap can land right before `!`, `?`, `/`, `)`, `]`, `}`, a closing quote or an ellipsis. Text such as `please fix this plz!`, `8.0/8.0`, `cli/install`, `(see)` or `β€œquoted”` then wraps with the closing mark orphaned at the start of the next line. ## Solution Add the "UAX 14 LB13 rule" to `LineWrapper::is_word_char`, which is to not break before `! ? / ) ] } " ” Β» …` ## Testing Added unit tests and manually tested myself with a long paragraph in a gpui app. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable (one extra `matches!` per character in the wrap loop) --- crates/gpui/src/text_system/line_wrapper.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index dc2b866..678e9e0 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -476,6 +476,12 @@ impl LineWrapper { // `2^3`, `a~b`, `a=1`, `Self::new`, etc. Trailing punctuation like `,`, `.`, `:`, `;` // is included so it stays attached to the preceding word when wrapping. matches!(c, '-' | '_' | '.' | '\'' | '’' | 'β€˜' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') || + // Closing punctuation never starts a line (UAX #14 LB13: no break + // before `!`, `)`, `]`, `}`, closing quotes or an ellipsis) β€” `plz!`, + // `see)`, `quoted”` wrap as one word instead of orphaning the mark on + // the next line. `/` and `?` stay break opportunities so long paths + // and URLs (`a/b`, `foo?b=2`) can wrap. + matches!(c, '!' | ')' | ']' | '}' | '"' | '”' | 'Β»' | '…') || // `β‹―` character is special used in Zed, to keep this at the end of the line. matches!(c, 'β‹―') || @@ -1152,6 +1158,10 @@ mod tests { assert_word("moreβ‹―"); assert_word("won’t"); assert_word("β€˜twas"); + assert_word("plz!"); + assert_word("see)"); + assert_word("quoted”"); + assert_word("well…"); // Space assert_not_word("foo bar"); From b49177c1e35980abf82f073bd03a2e538a75c013 Mon Sep 17 00:00:00 2001 From: Dinesh Yadav Date: Wed, 26 Aug 2026 08:42:18 +0000 Subject: [PATCH 11/45] gpui_windows: Fix window placement on secondary monitor with different DPI scaling (#62859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fixes #48927. On Windows, when monitors have different DPI scaling factors, a new window opened on a secondary monitor can be positioned partially off-screen, with the title bar unreachable. ## Solution `WindowsWindow::new` calls `CreateWindowExW` with `CW_USEDEFAULT` for the window's initial position, so Windows picks a starting spot (typically based on where the previous window was, which is often the primary monitor) before the window is explicitly moved to its intended monitor via `SetWindowPlacement`. `WindowsWindowState::new` immediately reads the window's scale factor via `GetDpiForWindow(hwnd)` at that point β€” i.e. the DPI of wherever Windows initially placed it, not necessarily the target monitor. `retrieve_window_placement` then used that scale factor to convert the target monitor's logical bounds into physical pixels. When the two monitors have different scaling (e.g. 150% vs 100%, as in the linked issue), this produces the wrong physical rect, leaving the window off-screen. The target `WindowsDisplay` already computes its own correct `scale_factor` from the actual monitor (`get_scale_factor_for_monitor`) β€” this is also what `check_given_bounds` already uses. This PR exposes that value via `WindowsDisplay::scale_factor()` and uses it in `retrieve_window_placement` instead of the transient window's scale factor, since the bounds being converted are expressed in logical pixels for that target display. ## Testing - Verified the logic by tracing through `WindowsWindowState::new` β†’ `retrieve_window_placement` β†’ `calculate_window_rect` and confirming the target monitor's own DPI (not the temporarily-assigned window DPI) is now used to compute the physical placement rect, matching the approach `WindowsDisplay::check_given_bounds` already uses for the same target/temporary-monitor mismatch. - I wasn't able to build/run Zed locally in this environment (missing Windows SDK components in the local toolchain, unrelated to this change) to reproduce the original multi-monitor/mixed-DPI repro steps first-hand. I'd appreciate a maintainer or anyone who can reproduce #48927 double-checking on real mixed-DPI hardware. - Platforms: Windows only (`gpui_windows` crate); no other platform code touched. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments (no new unsafe blocks added) - [ ] The content adheres to Zed's UI standards (N/A, no UI change) - [ ] Tests cover the new/changed behavior (no existing test harness for Windows-specific placement logic; happy to add one if pointed at the right pattern) - [x] Performance impact has been considered and is acceptable (no measurable impact; same number of DPI queries, just reading from the already-computed `WindowsDisplay` instead of the window) Release Notes: - Fixed: new windows on Windows could open partially off-screen when placed on a secondary monitor with a different DPI scaling factor than the monitor Windows initially placed them on. --------- Co-authored-by: Lukas Wirth Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/gpui_windows/src/display.rs | 6 ++++++ crates/gpui_windows/src/window.rs | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/gpui_windows/src/display.rs b/crates/gpui_windows/src/display.rs index bb75bf7..ee02309 100644 --- a/crates/gpui_windows/src/display.rs +++ b/crates/gpui_windows/src/display.rs @@ -92,6 +92,12 @@ impl WindowsDisplay { WindowsDisplay::new(Self::display_id_for_monitor(monitor)) } + /// The DPI scale factor of this monitor, independent of whatever monitor + /// a not-yet-positioned window currently happens to be on. + pub(crate) fn scale_factor(&self) -> f32 { + self.scale_factor + } + /// Check if the center point of given bounds is inside this monitor pub fn check_given_bounds(&self, bounds: Bounds) -> bool { let center = bounds.center(); diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index d81efac..37e9d9a 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -552,13 +552,8 @@ impl WindowsWindow { set_non_rude_hwnd(hwnd, true); configure_dwm_dark_mode(hwnd, appearance); this.state.border_offset.update(hwnd)?; - let placement = retrieve_window_placement( - hwnd, - display, - params.bounds, - this.state.scale_factor.get(), - &this.state.border_offset, - )?; + let placement = + retrieve_window_placement(hwnd, display, params.bounds, &this.state.border_offset)?; if params.show { let mut placement = placement; if !params.focus { @@ -1528,7 +1523,6 @@ fn retrieve_window_placement( hwnd: HWND, display: WindowsDisplay, initial_bounds: Bounds, - scale_factor: f32, border_offset: &WindowBorderOffset, ) -> Result { let mut placement = WINDOWPLACEMENT { @@ -1542,7 +1536,14 @@ fn retrieve_window_placement( } else { display.default_bounds() }; - let bounds = bounds.to_device_pixels(scale_factor); + // `bounds` is expressed in logical pixels for `display`, so it must be converted + // to device pixels using that display's own scale factor. The window's current + // scale factor can't be used here: `CreateWindowExW` was called with + // `CW_USEDEFAULT`, so at this point the window may still be sitting on whichever + // monitor Windows picked by default, which can have a different DPI than `display` + // and would otherwise throw off the physical position (e.g. leaving the window + // partially off-screen when moved to a monitor with a different scale factor). + let bounds = bounds.to_device_pixels(display.scale_factor()); placement.rcNormalPosition = calculate_window_rect(bounds, border_offset); Ok(placement) } From 7fed37a2bf10062c7330030376c763d01c49b54a Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:51:50 +0000 Subject: [PATCH 12/45] gpui: Separate benchmark support from test support (#63039) `gpui::bench` previously required GPUI's full `test-support` feature. Any benchmark using the supported GPUI harness therefore compiled test-only APIs and fakes, even when the benchmark otherwise followed production dependency paths. This change introduces `bench-support` as GPUI's canonical benchmark feature and keeps `bench` as a compatibility alias for existing consumers. The benchmark feature now exposes only the existing GPUI internals that `BenchAppContext` needs: the real threaded dispatcher, profiler integration, and a headless platform surface. Benchmark HTTP requests use the production `BlockedHttpClient`, so accidental network access fails instead of silently entering a fake response path. The current headless implementation reuses the cfg-stripped core of `TestPlatform`. Test-only prompt state, simulation APIs, and test executors remain unavailable under `bench-support`. Cargo feature-tree checks confirm that both `bench-support` and the compatibility alias remain independent of `test-support`. This also adds a `gpui-bench` agent skill covering production-shaped fixtures, frame and foreground responsiveness, macOS headless Metal rendering, deterministic correctness guards, Criterion measurement, Instruments symbolication, and the requirement that a benchmark's complete dependency graph contain no `test-support` feature. Two follow-ups are intentionally deferred to keep this feature-isolation change reviewable: - Extract the shared headless implementation into `HeadlessPlatformCore`, with separate `BenchPlatform` and `TestPlatform` wrappers. This will remove benchmark-related cfg branches from the test-facing type and make their supported surfaces explicit. - Add a shared scripted HTTP client that compiles independently under either `http_client/test-support` or `http_client/bench-support`. Tests and benchmarks could then serve predefined responses through the same deterministic transport boundary without either support feature enabling the other; the default benchmark context should remain network-blocked. This PR only isolates GPUI itself. Migrating downstream benchmark fixtures that explicitly request other crates' `test-support` features remains separate work. Testing performed: - `cargo tree --offline --package gpui --no-default-features --features bench-support --edges features --invert gpui` - `cargo tree --offline --package gpui --no-default-features --features bench --edges features --invert gpui` - `cargo check -p gpui --no-default-features --features bench-support` - `cargo check -p gpui --no-default-features --features bench` - `git diff --check` Release Notes: - N/A --- crates/gpui/Cargo.toml | 4 +- crates/gpui/src/app.rs | 6 +- crates/gpui/src/app/bench_context.rs | 6 +- crates/gpui/src/gpui.rs | 3 +- crates/gpui/src/platform.rs | 20 ++--- crates/gpui/src/platform/test.rs | 1 + crates/gpui/src/platform/test/platform.rs | 82 +++++++++++++++++-- crates/gpui/src/platform/test/window.rs | 1 + .../gpui/src/platform/threaded_dispatcher.rs | 6 +- crates/gpui/src/profiler.rs | 6 +- crates/gpui/src/window.rs | 2 +- 11 files changed, 109 insertions(+), 28 deletions(-) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 46126fb..6abaa9a 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -26,7 +26,9 @@ test-support = [ "x11", "proptest", ] -bench = ["test-support", "profiler", "dep:criterion"] +bench-support = ["profiler", "dep:criterion"] +# Preserve the published feature name while consumers migrate to `bench-support`. +bench = ["bench-support"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] wayland = [] diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 0c58c55..7182f96 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -24,7 +24,7 @@ use parking_lot::RwLock; use slotmap::SlotMap; pub use async_context::*; -#[cfg(feature = "bench")] +#[cfg(feature = "bench-support")] pub use bench_context::{BenchAppContext, BenchReport, BenchWindowContext, bench_platform}; use collections::{FxHashMap, FxHashSet, HashMap, TypeIdHashMap, TypeIdHashSet, VecDeque}; pub use context::*; @@ -60,7 +60,7 @@ use crate::{ }; mod async_context; -#[cfg(feature = "bench")] +#[cfg(feature = "bench-support")] mod bench_context; mod context; mod entity_map; @@ -1702,7 +1702,7 @@ impl App { } } } else { - #[cfg(any(test, feature = "test-support", feature = "bench"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] for window in self .windows .values() diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs index 7fc5831..278f26c 100644 --- a/crates/gpui/src/app/bench_context.rs +++ b/crates/gpui/src/app/bench_context.rs @@ -527,7 +527,11 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { ); let foreground_executor = platform.foreground_executor(); let asset_source = Arc::new(()); - let http_client = http_client::FakeHttpClient::with_404_response(); + // Benchmark setup must not make accidental network requests. The + // production `BlockedHttpClient` reports them without enabling a + // configurable test double through `test-support`. + let http_client: Arc = + Arc::new(http_client::BlockedHttpClient::new()); let app = App::new_app(platform, asset_source, http_client); Self { diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 927f634..41e3c47 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -43,7 +43,8 @@ pub mod profiler; target_os = "windows", target_os = "linux", target_family = "wasm", - feature = "test-support" + feature = "test-support", + feature = "bench-support" ))] #[expect(missing_docs)] pub mod queue; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 16ca199..44b0480 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -9,10 +9,10 @@ pub mod layer_shell; /// Types for configuring parent-anchored popup windows such as menus, dropdowns and tooltips. pub mod popup; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] mod threaded_dispatcher; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] mod test; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] @@ -47,7 +47,7 @@ use anyhow::bail; use anyhow::{Context as _, Result}; use async_task::Runnable; use futures::channel::oneshot; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] use image::RgbaImage; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, DynamicImage, Frame}; @@ -78,13 +78,13 @@ pub use app_menu::*; pub use keyboard::*; pub use keystroke::*; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub(crate) use test::*; #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub use threaded_dispatcher::ThreadedDispatcher; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] @@ -974,7 +974,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { /// Inform the adapter of updated window bounds. fn a11y_update_window_bounds(&self) {} - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn as_test(&mut self) -> Option<&mut TestWindow> { None } @@ -989,7 +989,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { } /// A renderer for headless windows that can produce real rendered output. -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub trait PlatformHeadlessRenderer { /// Render a scene and return the result as an RGBA image. fn render_scene_to_image( @@ -1055,14 +1055,14 @@ pub trait PlatformDispatcher: Send + Sync { gpui_util::defer(Box::new(|| {})) } - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn as_test(&self) -> Option<&TestDispatcher> { None } // This cfg must match the `threaded_dispatcher` module's, which implements // this method whenever it compiles. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn as_threaded(&self) -> Option<&ThreadedDispatcher> { None } @@ -1331,7 +1331,7 @@ pub trait PlatformAtlas { ) -> Result>; fn remove(&self, key: &AtlasKey); - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn contains(&self, _key: &AtlasKey) -> bool { false } diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index 9227df5..a327d8f 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -8,4 +8,5 @@ pub(crate) use display::*; pub(crate) use platform::*; pub(crate) use window::*; +#[cfg(any(test, feature = "test-support"))] pub use platform::{TestScreenCaptureSource, TestScreenCaptureStream}; diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 86c2297..5e82fbe 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,12 +1,17 @@ +#[cfg(any(test, feature = "test-support"))] +use crate::NoopTextSystem; +#[cfg(any(test, feature = "test-support"))] +use crate::PathPromptOptions; use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform, - PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, - PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, - SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task, - TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, + DummyKeyboardMapper, ForegroundExecutor, Keymap, Platform, PlatformDisplay, + PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString, + SourceMetadata, SystemNotification, SystemNotificationResponse, Task, TestDisplay, TestWindow, + ThermalState, WindowAppearance, WindowParams, size, }; use anyhow::Result; +#[cfg(any(test, feature = "test-support"))] use collections::VecDeque; use futures::channel::oneshot; use parking_lot::Mutex; @@ -30,6 +35,7 @@ pub(crate) struct TestPlatform { current_primary_item: Mutex>, #[cfg(target_os = "macos")] current_find_pasteboard_item: Mutex>, + #[cfg(any(test, feature = "test-support"))] pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, @@ -77,6 +83,7 @@ impl ScreenCaptureStream for TestScreenCaptureStream { } } +#[cfg(any(test, feature = "test-support"))] struct TestPrompt { msg: String, detail: Option, @@ -93,6 +100,7 @@ pub(crate) struct TestSystemNotifications { response_callback: Option>, } +#[cfg(any(test, feature = "test-support"))] #[derive(Default)] pub(crate) struct TestPrompts { multiple_choice: VecDeque, @@ -104,6 +112,7 @@ pub(crate) struct TestPrompts { } impl TestPlatform { + #[cfg(any(test, feature = "test-support"))] pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Rc { Self::with_platform( executor, @@ -113,6 +122,7 @@ impl TestPlatform { ) } + #[cfg(any(test, feature = "test-support"))] pub fn with_text_system( executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, @@ -132,6 +142,7 @@ impl TestPlatform { Rc::new_cyclic(|weak| TestPlatform { background_executor: executor, foreground_executor, + #[cfg(any(test, feature = "test-support"))] prompts: Default::default(), screen_capture_sources: Default::default(), active_cursor: Default::default(), @@ -151,6 +162,7 @@ impl TestPlatform { }) } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn simulate_new_path_selection( &self, select_path: impl FnOnce(&std::path::Path) -> Option, @@ -164,6 +176,7 @@ impl TestPlatform { tx.send(Ok(select_path(&path))).ok(); } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn simulate_path_prompt_response( &self, select_paths: impl FnOnce(&PathPromptOptions) -> Option>, @@ -187,10 +200,12 @@ impl TestPlatform { tx.send(Ok(selection)).ok(); } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn did_prompt_for_paths(&self) -> bool { !self.prompts.borrow().paths.is_empty() } + #[cfg(any(test, feature = "test-support"))] #[track_caller] pub(crate) fn simulate_prompt_answer(&self, response: &str) { let prompt = self @@ -208,10 +223,12 @@ impl TestPlatform { prompt.tx.send(ix).ok(); } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn has_pending_prompt(&self) -> bool { !self.prompts.borrow().multiple_choice.is_empty() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn pending_prompt(&self) -> Option<(String, String)> { let prompts = self.prompts.borrow(); let prompt = prompts.multiple_choice.front()?; @@ -221,10 +238,14 @@ impl TestPlatform { )) } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn set_screen_capture_sources(&self, sources: Vec) { *self.screen_capture_sources.borrow_mut() = sources; } + /// Queues the prompt so a test can later inspect or answer it through + /// [`Self::pending_prompt`] and [`Self::simulate_prompt_answer`]. + #[cfg(any(test, feature = "test-support"))] pub(crate) fn prompt( &self, msg: &str, @@ -245,6 +266,19 @@ impl TestPlatform { rx } + /// Benchmarks have no API to answer a prompt, so this doesn't retain it + /// for later inspection; dropping the sender immediately cancels the + /// returned receiver instead of leaving it pending indefinitely. + #[cfg(not(any(test, feature = "test-support")))] + pub(crate) fn prompt( + &self, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> oneshot::Receiver { + oneshot::channel().1 + } + pub(crate) fn set_active_window(&self, window: Option) { let executor = self.foreground_executor(); let previous_window = self.active_window.borrow_mut().take(); @@ -267,26 +301,32 @@ impl TestPlatform { .detach(); } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn did_prompt_for_new_path(&self) -> bool { !self.prompts.borrow().new_path.is_empty() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn app_identity(&self) -> Option<(SharedString, SharedString)> { self.system_notifications.borrow().app_identity.clone() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn shown_system_notifications(&self) -> Vec { self.system_notifications.borrow().shown.clone() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn delivered_system_notifications(&self) -> Vec { self.system_notifications.borrow().delivered.clone() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn dismissed_system_notifications(&self) -> Vec { self.system_notifications.borrow().dismissed.clone() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn simulate_system_notification_response( &self, response: SystemNotificationResponse, @@ -424,6 +464,9 @@ impl Platform for TestPlatform { unimplemented!() } + /// Queues the prompt so a test can later answer it through + /// [`Self::simulate_path_prompt_response`]. + #[cfg(any(test, feature = "test-support"))] fn prompt_for_paths( &self, options: crate::PathPromptOptions, @@ -433,6 +476,21 @@ impl Platform for TestPlatform { rx } + /// Benchmarks have no API to answer a path prompt, so this doesn't + /// retain it for later inspection; dropping the sender immediately + /// cancels the returned receiver instead of leaving it pending + /// indefinitely. + #[cfg(not(any(test, feature = "test-support")))] + fn prompt_for_paths( + &self, + _options: crate::PathPromptOptions, + ) -> oneshot::Receiver>>> { + oneshot::channel().1 + } + + /// Queues the prompt so a test can later answer it through + /// [`Self::simulate_new_path_selection`]. + #[cfg(any(test, feature = "test-support"))] fn prompt_for_new_path( &self, directory: &std::path::Path, @@ -446,6 +504,19 @@ impl Platform for TestPlatform { rx } + /// Benchmarks have no API to answer a new-path prompt, so this doesn't + /// retain it for later inspection; dropping the sender immediately + /// cancels the returned receiver instead of leaving it pending + /// indefinitely. + #[cfg(not(any(test, feature = "test-support")))] + fn prompt_for_new_path( + &self, + _directory: &std::path::Path, + _suggested_name: Option<&str>, + ) -> oneshot::Receiver>> { + oneshot::channel().1 + } + fn can_select_mixed_files_and_dirs(&self) -> bool { true } @@ -586,6 +657,7 @@ impl Platform for TestPlatform { impl TestScreenCaptureSource { /// Create a fake screen capture source, for testing. + #[cfg(any(test, feature = "test-support"))] pub fn new() -> Self { Self {} } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 939c8a8..466b965 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -7,6 +7,7 @@ use crate::{ }; use collections::HashMap; use gpui_util::ResultExt as _; +#[cfg(any(test, feature = "test-support"))] use image::RgbaImage; use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; diff --git a/crates/gpui/src/platform/threaded_dispatcher.rs b/crates/gpui/src/platform/threaded_dispatcher.rs index ff45e1c..762464a 100644 --- a/crates/gpui/src/platform/threaded_dispatcher.rs +++ b/crates/gpui/src/platform/threaded_dispatcher.rs @@ -268,7 +268,7 @@ impl ThreadedDispatcher { /// drains β€” deferred work that re-queues itself (idle sweeps, pollers) /// must not extend a benchmark's measured interval past the completion it /// awaits. - #[cfg(any(test, feature = "bench"))] + #[cfg(any(test, feature = "bench-support"))] pub(crate) fn run_until(&self, mut ready: impl FnMut() -> Option) -> R { assert!( self.is_main_thread(), @@ -296,7 +296,7 @@ impl ThreadedDispatcher { /// readiness between them: a task that perpetually re-queues itself (like /// an idle-time sweep) would otherwise keep [`Self::drain_main_queue`] /// looping past the completion the caller is waiting for. - #[cfg(any(test, feature = "bench"))] + #[cfg(any(test, feature = "bench-support"))] fn run_one_main_task(&self) -> bool { let runnable = self.main_receiver.lock().try_pop(); match runnable { @@ -373,7 +373,7 @@ impl ThreadedDispatcher { /// Whether no main-thread work is queued, no background or timer /// runnables are queued or running, and no armed timer is due. Timers /// that aren't due yet are ignored, as in [`Self::run_until_idle`]. - #[cfg(any(test, feature = "bench"))] + #[cfg(any(test, feature = "bench-support"))] pub(crate) fn is_idle(&self) -> bool { !self.main_queue_has_work() && !self.has_due_timer() && *self.idle.inflight.lock() == 0 } diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index f3dc0ce..340f054 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -734,10 +734,10 @@ pub fn set_trace_enabled(enabled: bool) -> bool { } } -#[cfg(any(feature = "bench", all(test, feature = "profiler")))] +#[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] pub(crate) struct TraceGuard; -#[cfg(any(feature = "bench", all(test, feature = "profiler")))] +#[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] pub(crate) fn trace_scope() -> TraceGuard { let incremented = TRACE_STATE.fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| { (state & TRACE_SCOPE_COUNT_MASK < TRACE_SCOPE_COUNT_MASK).then_some(state + 1) @@ -746,7 +746,7 @@ pub(crate) fn trace_scope() -> TraceGuard { TraceGuard } -#[cfg(any(feature = "bench", all(test, feature = "profiler")))] +#[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] impl Drop for TraceGuard { fn drop(&mut self) { let previous_state = diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 976eadd..ba4cb05 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3069,7 +3069,7 @@ impl Window { /// Benchmarks drive drawing synchronously rather than through a platform /// frame-request loop, so they call this after each measured update to /// submit the frame like production presentation would. - #[cfg(any(feature = "bench", all(test, feature = "profiler")))] + #[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] pub fn present_if_needed(&mut self) { if self.needs_present.get() { self.present(); From cade19e69f17430c2f2daa258020329750cd8717 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:00:10 +0000 Subject: [PATCH 13/45] gpui_platform: Expose headless renderer through bench support (#63244) GPUI's `bench-support` feature provides the benchmark context and headless-renderer interface without enabling `test-support`, but the platform implementation remained available only through `gpui_platform/test-support`. As a result, consumers could not use `#[gpui::bench]` with a fully isolated feature graph because the generated benchmark harness calls `gpui_platform::current_headless_renderer()`. This threads `bench-support` through `gpui_platform`, `gpui_macos`, and `gpui_apple`, exposing the existing Metal headless renderer without widening unrelated test window or prompt APIs. The existing benchmark package now requests the platform's canonical `bench-support` feature. Its other test-backed fixtures are intentionally unchanged and remain outside this focused dependency fix. Validation: - Confirmed `gpui_apple/bench-support`, `gpui_macos/bench-support`, and `gpui_platform/bench-support` resolve no `test-support` feature with `cargo tree --locked --no-default-features --features bench-support -e no-dev,features`. - Ran `cargo check --locked -p benchmarks --benches` to compile every existing `#[gpui::bench]` target against the real platform headless renderer. - Ran `cargo nextest run --locked -p gpui_apple -p gpui_macos -p gpui_platform`. - Ran `./script/clippy` for each changed platform package with `--no-default-features --features bench-support`. - Ran `cargo fmt --check`. Release Notes: - N/A --- crates/gpui_apple/Cargo.toml | 1 + crates/gpui_apple/src/metal_renderer.rs | 20 ++++++++++---------- crates/gpui_macos/Cargo.toml | 1 + crates/gpui_macos/src/gpui_macos.rs | 2 +- crates/gpui_platform/Cargo.toml | 1 + crates/gpui_platform/src/gpui_platform.rs | 2 +- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/gpui_apple/Cargo.toml b/crates/gpui_apple/Cargo.toml index fb5f07a..1d8db9b 100644 --- a/crates/gpui_apple/Cargo.toml +++ b/crates/gpui_apple/Cargo.toml @@ -13,6 +13,7 @@ path = "src/gpui_apple.rs" [features] default = [] +bench-support = ["gpui/bench-support"] test-support = ["gpui/test-support"] runtime_shaders = [] diff --git a/crates/gpui_apple/src/metal_renderer.rs b/crates/gpui_apple/src/metal_renderer.rs index 245a065..f1822a1 100644 --- a/crates/gpui_apple/src/metal_renderer.rs +++ b/crates/gpui_apple/src/metal_renderer.rs @@ -10,7 +10,7 @@ use gpui::{ AtlasTextureId, Background, Bounds, ContentMask, DevicePixels, LayerEffect, PaintSurface, Path, Point, PrimitiveBatch, ScaledPixels, Scene, Size, point, size, }; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] use image::RgbaImage; use core_foundation::base::TCFType; @@ -149,7 +149,7 @@ pub struct MetalRenderer { filter_targets: Option, /// Offscreen render target reused across `render_scene` calls when /// rendering headlessly without reading pixels back. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] headless_render_target: Option, } @@ -193,7 +193,7 @@ impl MetalRenderer { /// /// This renderer can render scenes to images without requiring a CAMetalLayer, /// window, or AppKit. Use `render_scene_to_image()` to render scenes. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub fn new_headless(instance_buffer_pool: Arc>) -> Self { let device = Self::create_device(); Self::new_internal(device, None, true, instance_buffer_pool) @@ -417,7 +417,7 @@ impl MetalRenderer { path_intermediate_msaa_texture: None, path_sample_count: PATH_SAMPLE_COUNT, filter_targets: None, - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] headless_render_target: None, } } @@ -630,7 +630,7 @@ impl MetalRenderer { /// /// This is the primary method for headless rendering. It creates an offscreen /// texture, renders the scene to it, and returns the pixel data as an RGBA image. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub fn render_scene_to_image( &mut self, scene: &Scene, @@ -678,7 +678,7 @@ impl MetalRenderer { /// encoding, instance buffer writes, command submission) and is used by /// headless benchmark rendering, where the produced pixels are never /// inspected. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub fn render_scene(&mut self, scene: &Scene, size: Size) -> Result<()> { if size.width.0 <= 0 || size.height.0 <= 0 { anyhow::bail!("Invalid size for render_scene: {:?}", size); @@ -1752,7 +1752,7 @@ fn new_command_encoder_for_texture<'a>( command_encoder } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] fn read_texture_to_image(texture: &metal::TextureRef) -> Result { let width = texture.width() as u32; let height = texture.height() as u32; @@ -2231,12 +2231,12 @@ pub struct SurfaceBounds { pub content_mask: ContentMask, } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub struct MetalHeadlessRenderer { renderer: MetalRenderer, } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] impl MetalHeadlessRenderer { pub fn new() -> Self { let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default())); @@ -2245,7 +2245,7 @@ impl MetalHeadlessRenderer { } } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer { fn render_scene_to_image( &mut self, diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index ff10902..22bc2ab 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -13,6 +13,7 @@ path = "src/gpui_macos.rs" [features] default = ["gpui/default"] +bench-support = ["gpui/bench-support", "gpui_apple/bench-support"] test-support = ["gpui/test-support", "gpui_apple/test-support"] runtime_shaders = ["gpui_apple/runtime_shaders"] font-kit = ["dep:font-kit"] diff --git a/crates/gpui_macos/src/gpui_macos.rs b/crates/gpui_macos/src/gpui_macos.rs index 1ee1872..1e393e4 100644 --- a/crates/gpui_macos/src/gpui_macos.rs +++ b/crates/gpui_macos/src/gpui_macos.rs @@ -20,7 +20,7 @@ use gpui_apple::metal_renderer as renderer; pub mod metal_renderer { pub use gpui_apple::metal_renderer::{PathRasterizationVertex, PathSprite, SurfaceBounds}; - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub use gpui_apple::metal_renderer::MetalHeadlessRenderer; } diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index 5c9c14a..052ecd5 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -13,6 +13,7 @@ path = "src/gpui_platform.rs" [features] default = [] +bench-support = ["gpui/bench-support", "gpui_macos/bench-support"] font-kit = ["gpui_linux/font-kit", "gpui_macos/font-kit"] runtime_shaders = ["gpui_macos/runtime_shaders"] test-support = ["gpui/test-support"] diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs index 7e37b0f..289fba6 100644 --- a/crates/gpui_platform/src/gpui_platform.rs +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -48,7 +48,7 @@ pub fn current_platform(headless: bool) -> Rc { } /// Returns a new [`HeadlessRenderer`] for the current platform, if available. -#[cfg(feature = "test-support")] +#[cfg(any(feature = "bench-support", feature = "test-support"))] pub fn current_headless_renderer() -> Option> { #[cfg(target_os = "macos")] { From 9940b5b025981a84398347d92b50a446bb44422a Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 26 Aug 2026 14:33:13 +0000 Subject: [PATCH 14/45] Bump dependencies to dedupe the crate graph (#63231) Deduplicates crates with different versions from the graph, drops unused features. `cargo tree -d duplicate` listing: 160 -> 112 lines.

Deduplicated list ``` base64 (v0.22.1, v0.21.7 -> v0.22.1) convert_case (v0.11.0, v0.10.0, v0.8.0 -> v0.11.0, v0.10.0) core-foundation (v0.10.0, v0.9.4 -> v0.10.0) cssparser (v0.36.0, v0.35.0 -> v0.37.0, v0.36.0) fancy-regex (v0.18.0, v0.16.2 -> v0.19.0) fixedbitset (v0.5.7, v0.4.2 -> v0.5.7) h2 (v0.4.12, v0.3.27 -> v0.4.12) heck (v0.5.0, v0.4.1, v0.3.3 -> v0.5.0, v0.4.1) html5ever (v0.39.0, v0.35.0 -> v0.39.0) hyper (v1.7.0, v0.14.32 -> v1.7.0) hyper-rustls (v0.27.9, v0.24.2 -> v0.27.9) mach2 (v0.6.0, v0.5.0, v0.4.3 -> v0.6.0, v0.5.0) markup5ever (v0.39.0, v0.35.0 -> v0.39.0) phf (v0.13.1, v0.12.1, v0.11.3 -> v0.13.1, v0.11.3) phf_codegen (v0.13.1, v0.11.3 -> v0.13.1) phf_generator (v0.13.1, v0.12.1, v0.11.3 -> v0.13.1, v0.11.3) phf_macros (v0.13.1, v0.12.1, v0.11.3 -> v0.13.1, v0.11.3) phf_shared (v0.13.1, v0.12.1, v0.11.3 -> v0.13.1, v0.11.3) prost (v0.12.6, v0.9.0 -> v0.14.4) prost-build (v0.12.6, v0.9.0 -> v0.14.4) prost-derive (v0.12.6, v0.9.0 -> v0.14.4) prost-types (v0.12.6, v0.9.0 -> v0.14.4) rustix (v1.1.4, v0.38.44 -> v1.1.4) rustls (v0.23.40, v0.21.12 -> v0.23.40) rustls-native-certs (v0.8.3, v0.6.3 -> v0.8.3) rustls-pemfile (v2.2.0, v1.0.4 -> v2.2.0) rustls-webpki (v0.103.13, v0.101.7 -> v0.103.13) security-framework (v3.5.1, v2.11.1 -> v3.5.1) socket2 (v0.6.3, v0.5.10 -> v0.6.3) string_cache (v0.9.0, v0.8.9 -> v0.9.0) string_cache_codegen (v0.6.1, v0.5.4 -> v0.6.1) strum (v0.28.0, v0.27.2 -> v0.28.0) strum_macros (v0.28.0, v0.27.2 -> v0.28.0) tendril (v0.5.1, v0.4.3 -> v0.5.1) tokio-rustls (v0.26.4, v0.24.1 -> v0.26.4) tungstenite (v0.28.0, v0.27.0 -> v0.28.0) web_atoms (v0.2.6, v0.1.3 -> v0.2.6) which (v6.0.3, v4.4.2 -> v8.0.5) ```
Release Notes: - N/A --- crates/util/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 0ab698b..bebac88 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -60,7 +60,7 @@ nix = { workspace = true, features = ["user"] } mach2.workspace = true [target.'cfg(windows)'.dependencies] -tendril = "0.4.3" +tendril = "0.5" windows.workspace = true [dev-dependencies] From 478daf2322dfb753b372b94825d53aa3600ca882 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 26 Aug 2026 17:57:23 +0000 Subject: [PATCH 15/45] gpui: Text input configuration and web implementation (#63264) Allows setting various attributes for the underlying mirror textarea on the web. Allows GPUI app authors to specify things like "this element should be auto-corrected" --- crates/gpui/src/input.rs | 207 +++++++++++++++++++++++- crates/gpui/src/platform.rs | 92 +++++++++++ crates/gpui/src/platform/test/window.rs | 15 +- crates/gpui/src/window.rs | 27 ++++ 4 files changed, 338 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index 6de5719..70ac06e 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -1,5 +1,6 @@ use crate::{ - App, Bounds, ClipboardItem, Context, Entity, InputHandler, Pixels, UTF16Selection, Window, + App, Bounds, ClipboardItem, Context, Entity, InputHandler, Pixels, TextInputConfiguration, + UTF16Selection, Window, }; use std::ops::Range; @@ -102,6 +103,15 @@ pub trait EntityInputHandler: 'static + Sized { fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { true } + + /// See [`InputHandler::text_input_configuration`] for details + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> TextInputConfiguration { + TextInputConfiguration::default() + } } /// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`] @@ -244,4 +254,199 @@ impl InputHandler for ElementInputHandler { self.view .update(cx, |view, cx| view.accepts_text_input(window, cx)) } + + fn text_input_configuration( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> TextInputConfiguration { + self.view + .update(cx, |view, cx| view.text_input_configuration(window, cx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + AnyWindowHandle, AppContext as _, FocusHandle, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, canvas, div, + }; + + #[gpui::test] + fn text_input_configuration_forwarded_only_on_change(cx: &mut TestAppContext) { + let custom = TextInputConfiguration { + autocorrect: true, + input_action: TextInputAction::Send, + ..Default::default() + }; + let window = cx.add_window({ + let custom = custom.clone(); + move |_, cx| ConfigurationTestView { + focus_handle: cx.focus_handle(), + configuration: custom, + } + }); + let view = window.root(cx).unwrap(); + let test_window = cx.test_window(window.into()); + let window = AnyWindowHandle::from(window); + let draw = |cx: &mut TestAppContext| { + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + }; + + // Nothing is focused, so the platform learns the default configuration. + draw(cx); + assert_eq!( + test_window.text_input_configurations(), + vec![TextInputConfiguration::default()] + ); + + // Focusing the view routes its configuration to the platform. + cx.update_window(window, |_, window, cx| { + let focus_handle = view.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + }) + .unwrap(); + draw(cx); + assert_eq!( + test_window.text_input_configurations(), + vec![TextInputConfiguration::default(), custom.clone()] + ); + + // Redrawing without a change forwards nothing. + draw(cx); + assert_eq!(test_window.text_input_configurations().len(), 2); + + // Changing the configuration forwards the new value. + let updated = TextInputConfiguration { + suggestions: true, + ..custom + }; + view.update(cx, { + let updated = updated.clone(); + |view, cx| { + view.configuration = updated; + cx.notify(); + } + }); + draw(cx); + assert_eq!( + test_window.text_input_configurations().last(), + Some(&updated) + ); + assert_eq!(test_window.text_input_configurations().len(), 3); + + // Losing focus reverts the platform to the default configuration. + cx.update_window(window, |_, window, _| window.blur()) + .unwrap(); + draw(cx); + assert_eq!( + test_window.text_input_configurations().last(), + Some(&TextInputConfiguration::default()) + ); + assert_eq!(test_window.text_input_configurations().len(), 4); + } + + struct ConfigurationTestView { + focus_handle: FocusHandle, + configuration: TextInputConfiguration, + } + + impl Render for ConfigurationTestView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let focus_handle = self.focus_handle.clone(); + div().size_full().track_focus(&self.focus_handle).child( + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, view), + cx, + ); + }, + ) + .size_full(), + ) + } + } + + impl EntityInputHandler for ConfigurationTestView { + fn text_for_range( + &mut self, + _range: std::ops::Range, + _adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn replace_text_in_range( + &mut self, + _range: Option>, + _text: &str, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn replace_and_mark_text_in_range( + &mut self, + _range: Option>, + _new_text: &str, + _new_selected_range: Option>, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn bounds_for_range( + &mut self, + _range_utf16: std::ops::Range, + _element_bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: crate::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> TextInputConfiguration { + self.configuration.clone() + } + } } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 44b0480..1f07948 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -827,6 +827,11 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn capslock(&self) -> Capslock; fn set_input_handler(&mut self, input_handler: PlatformInputHandler); fn take_input_handler(&mut self) -> Option; + /// Apply the focused text region's [`TextInputConfiguration`] to the + /// platform's text input session (e.g. attributes of the hidden editable + /// element on web). Called only when the configuration changes, because + /// reconfiguring a live input session can restart the IME connection. + fn set_text_input_configuration(&mut self, _configuration: TextInputConfiguration) {} fn prompt( &self, level: PromptLevel, @@ -1654,6 +1659,15 @@ impl PlatformInputHandler { }) .unwrap_or(false) } + + /// See [`InputHandler::text_input_configuration`]. + pub fn text_input_configuration( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> TextInputConfiguration { + self.handler.text_input_configuration(window, cx) + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1823,6 +1837,84 @@ pub trait InputHandler: 'static { fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, _cx: &mut App) -> bool { false } + + /// Get this handler's preferences for platform text assistance. + /// + /// GPUI re-queries this every frame and forwards it to the platform window + /// only when it changes, so implementations must be cheap and may vary the + /// result with application state (e.g. with the cursor's position). + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut App, + ) -> TextInputConfiguration { + TextInputConfiguration::default() + } +} + +/// Platform text-assistance preferences for the focused text region. +/// +/// Returned by [`InputHandler::text_input_configuration`] and forwarded to the +/// platform whenever it changes; the platform maps the fields onto its native +/// input-session attributes (on web, DOM attributes of the hidden editable +/// element such as `autocorrect` and `enterkeyhint`). +/// +/// The default disables all text assistance and requests no particular action +/// key presentation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextInputConfiguration { + /// Whether the platform may automatically correct entered text. + pub autocorrect: bool, + /// How software keyboards automatically capitalize entered text. + pub autocapitalize: Autocapitalize, + /// Whether software keyboards may offer word suggestions and spellcheck. + pub suggestions: bool, + /// The action advertised on a software keyboard's confirm ("enter") key. + pub input_action: TextInputAction, +} + +/// Automatic capitalization applied by software keyboards. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Autocapitalize { + /// No automatic capitalization. + #[default] + None, + /// Capitalize the first letter of each word. + Words, + /// Capitalize the first letter of each sentence. + Sentences, + /// Capitalize every letter. + Characters, +} + +/// The action a software keyboard advertises on its confirm ("enter") key. +/// +/// This affects only how the key is presented (icon or label); pressing it is +/// still delivered as ordinary input. +/// +/// The variants are the HTML `enterkeyhint` attribute's value set +/// (), +/// which also maps onto Android's `IME_ACTION_*` constants and iOS's +/// `UIReturnKeyType`; [`TextInputAction::Unspecified`] means "emit no hint". +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextInputAction { + /// Let the platform choose its default presentation. + #[default] + Unspecified, + /// Inserting a line break. + Enter, + /// Committing the field's value. + Done, + /// Navigating to the typed target. + Go, + /// Moving to the next field. + Next, + /// Moving to the previous field. + Previous, + /// Executing a search. + Search, + /// Sending a message. + Send, } /// The variables that can be configured when creating a new window diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 466b965..223a237 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -2,8 +2,8 @@ use crate::{ AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, - PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, - WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, + PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TileId, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; use gpui_util::ResultExt as _; @@ -42,6 +42,7 @@ pub(crate) struct TestWindowState { frame_scheduled: bool, frame_callback_pending: bool, input_handler: Option, + text_input_configurations: Vec, is_fullscreen: bool, appearance: WindowAppearance, external_drag_files: Vec<(PathBuf, bool)>, @@ -104,6 +105,7 @@ impl TestWindow { frame_scheduled: false, frame_callback_pending: false, input_handler: None, + text_input_configurations: Vec::new(), is_fullscreen: false, appearance: WindowAppearance::Light, external_drag_files: Vec::new(), @@ -133,6 +135,11 @@ impl TestWindow { self.0.lock().frame_scheduled } + /// Every [`TextInputConfiguration`] forwarded to this window, in order. + pub fn text_input_configurations(&self) -> Vec { + self.0.lock().text_input_configurations.clone() + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -258,6 +265,10 @@ impl PlatformWindow for TestWindow { self.0.lock().input_handler.take() } + fn set_text_input_configuration(&mut self, configuration: TextInputConfiguration) { + self.0.lock().text_input_configurations.push(configuration); + } + fn prompt( &self, _level: crate::PromptLevel, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index ba4cb05..c67af8b 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -19,6 +19,7 @@ use crate::{ 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, + TextInputConfiguration, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, @@ -1165,6 +1166,10 @@ pub struct Window { pub(crate) element_opacity: f32, pub(crate) content_mask_stack: Vec>, pub(crate) requested_autoscroll: Option>, + /// The [`TextInputConfiguration`] most recently forwarded to the platform + /// window, so that only actual changes are forwarded (reconfiguring a live + /// input session can restart the IME connection). + last_text_input_configuration: Option, pub(crate) image_cache_stack: Vec, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, @@ -1858,6 +1863,7 @@ impl Window { content_mask_stack: Vec::new(), element_opacity: 1.0, requested_autoscroll: None, + last_text_input_configuration: None, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame_callbacks, @@ -2930,6 +2936,7 @@ impl Window { { self.platform_window.set_input_handler(input_handler); } + self.apply_text_input_configuration(cx); self.layout_engine.as_mut().unwrap().clear(); self.text_system().finish_frame(); @@ -5058,6 +5065,26 @@ impl Window { } } + /// Forwards the focused input handler's [`TextInputConfiguration`] to the + /// platform window when it differs from the last forwarded value. With no + /// input handler the default configuration applies, so a field's + /// preferences don't outlive its focus. + fn apply_text_input_configuration(&mut self, cx: &mut App) { + let configuration = match self.platform_window.take_input_handler() { + Some(mut input_handler) => { + let configuration = input_handler.text_input_configuration(self, cx); + self.platform_window.set_input_handler(input_handler); + configuration + } + None => TextInputConfiguration::default(), + }; + if self.last_text_input_configuration.as_ref() != Some(&configuration) { + self.platform_window + .set_text_input_configuration(configuration.clone()); + self.last_text_input_configuration = Some(configuration); + } + } + /// Register a mouse event listener on the window for the next frame. The type of event /// is determined by the first parameter of the given listener. When the next frame is rendered /// the listener will be cleared. From fa6ef0df2d7ed9f92d86e2c32e2c25c23a2b2952 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Wed, 26 Aug 2026 21:36:59 +0000 Subject: [PATCH 16/45] gpui: Fix stale pending input when a window is blurred (#63271) This prepares for the keybinding hints PR. To reproduce, press `ctrl-b` in a GPUI app with `ctrl-b h` and `ctrl-b j` bindings, then call `window.blur()`. The live pending state clears, but observers still report `ctrl-b`. `Window::blur` now clears pending input and notifies observers after the current effect cycle. I don't think we currently exercise this path in Zed, and I was only able to reproduce it with an example GPUI app. Release Notes: - N/A --- crates/gpui/src/app.rs | 9 +++---- crates/gpui/src/input.rs | 2 +- crates/gpui/src/key_dispatch.rs | 34 +++++++++++++++++++++--- crates/gpui/src/window.rs | 47 ++++++++++++++++++++------------- 4 files changed, 63 insertions(+), 29 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 7182f96..80e9f73 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1764,9 +1764,9 @@ impl App { if focus.ref_count.load(SeqCst) == 0 { for window_handle in self.windows() { window_handle - .update(self, |_, window, _| { + .update(self, |_, window, cx| { if window.focus == Some(handle_id) { - window.blur(); + window.blur(cx); } }) .unwrap(); @@ -2390,10 +2390,7 @@ impl App { for window in self.windows() { window .update(self, |_, window, cx| { - if window.pending_input_keystrokes().is_some() { - window.clear_pending_keystrokes(); - window.pending_input_changed(cx); - } + window.clear_pending_keystrokes(cx); }) .ok(); } diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index 70ac06e..b3b5dd0 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -338,7 +338,7 @@ mod tests { assert_eq!(test_window.text_input_configurations().len(), 3); // Losing focus reverts the platform to the default configuration. - cx.update_window(window, |_, window, _| window.blur()) + cx.update_window(window, |_, window, cx| window.blur(cx)) .unwrap(); draw(cx); assert_eq!( diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 7b18d41..5a037eb 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -830,7 +830,7 @@ mod tests { } #[crate::test] - fn test_pending_input_observers_notified_on_focus_change(cx: &mut TestAppContext) { + fn test_pending_input_observers_notified_on_focus_change_and_blur(cx: &mut TestAppContext) { #[derive(Clone)] struct CustomElement { focus_handle: FocusHandle, @@ -988,6 +988,8 @@ mod tests { cx.update(|cx| { cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-d", TestAction, None)]); + cx.bind_keys([KeyBinding::new("ctrl-d h", TestAction, None)]); }); let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); @@ -1034,6 +1036,30 @@ mod tests { let count_after_focus_change = *pending_input_changed_count.borrow(); assert!(count_after_focus_change > *count_after_pending_for_assertion.borrow()); }); + + cx.update(|window, cx| window.focus(&focus_handle, cx)); + cx.simulate_keystrokes("ctrl-b"); + let count_before_blur = *pending_input_changed_count.borrow(); + + cx.update(|window, cx| { + assert!(window.has_pending_keystrokes()); + window.blur(cx); + assert!(!window.has_pending_keystrokes()); + assert!(window.pending_input_is_none()); + }); + + cx.update(|_, _| { + assert!(*pending_input_changed_count.borrow() > count_before_blur); + }); + + cx.update(|window, cx| window.disable_focus(cx)); + cx.simulate_keystrokes("ctrl-d"); + + cx.update(|window, cx| { + assert!(window.has_pending_keystrokes()); + window.blur(cx); + assert!(window.pending_input_is_none()); + }); } #[crate::test] @@ -1226,10 +1252,10 @@ mod tests { let prefers_ime_after_blur = { let mut platform_window = cx.test_window(cx.window_handle()); let mut input_handler = platform_window.take_input_handler(); - cx.update(|window, _| { - window.blur(); + cx.update(|window, cx| { + window.blur(cx); assert!(!window.has_pending_keystrokes()); - assert!(window.pending_input_keystrokes().is_none()); + assert!(window.pending_input_is_none()); }); let prefers_ime = input_handler .as_mut() diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c67af8b..2c70058 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2074,24 +2074,15 @@ impl Window { self.focus = Some(handle.id); self.focus_generation = self.focus_generation.wrapping_add(1); - self.clear_pending_keystrokes(); - - // Avoid re-entrant entity updates by deferring observer notifications to the end of the - // current effect cycle, and only for this window. - let window_handle = self.handle; - cx.defer(move |cx| { - window_handle - .update(cx, |_, window, cx| { - window.pending_input_changed(cx); - }) - .ok(); - }); + self.clear_pending_keystrokes(cx); self.refresh(); } /// Remove focus from all elements within this context's window. - pub fn blur(&mut self) { + pub fn blur(&mut self, cx: &mut App) { + self.clear_pending_keystrokes(cx); + if !self.focus_enabled { return; } @@ -2104,8 +2095,8 @@ impl Window { } /// Blur the window and don't allow anything in it to be focused again. - pub fn disable_focus(&mut self) { - self.blur(); + pub fn disable_focus(&mut self, cx: &mut App) { + self.blur(cx); self.focus_enabled = false; } @@ -5677,6 +5668,19 @@ impl Window { .retain(&(), |callback| callback(self, cx)); } + fn defer_pending_input_changed(&self, cx: &mut App) { + // Avoid re-entrant entity updates by deferring observer notifications to the end of the + // current effect cycle, and only for this window. + let window_handle = self.handle; + cx.defer(move |cx| { + window_handle + .update(cx, |_, window, cx| { + window.pending_input_changed(cx); + }) + .ok(); + }); + } + fn dispatch_key_down_up_event( &mut self, event: &dyn Any, @@ -5741,8 +5745,15 @@ impl Window { self.active_pending_input().is_some() } - pub(crate) fn clear_pending_keystrokes(&mut self) { - self.pending_input.take(); + #[cfg(test)] + pub(crate) fn pending_input_is_none(&self) -> bool { + self.pending_input.is_none() + } + + pub(crate) fn clear_pending_keystrokes(&mut self, cx: &mut App) { + if self.pending_input.take().is_some() { + self.defer_pending_input_changed(cx); + } } /// Returns the currently pending input keystrokes that might result in a multi-stroke key binding. @@ -6400,7 +6411,7 @@ impl Window { } } accesskit::Action::Blur => { - self.blur(); + self.blur(cx); } _ => { log::debug!( From 989e6e8bf6484782c868bc3453dab7332105e42f Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 27 Aug 2026 10:13:49 +0000 Subject: [PATCH 17/45] gpui: Add text_edit_editable_range API (#63294) When syncing text to the browser IME mirror, we sometimes need to mark a particular range as "editable". Native clients have a pull-based model, so don't need this mechanism, but on the web we need to proactively push content to the IME mirror `textarea`. --- crates/gpui/src/input.rs | 18 ++++++++++++++++++ crates/gpui/src/platform.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index b3b5dd0..cbf6e40 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -112,6 +112,15 @@ pub trait EntityInputHandler: 'static + Sized { ) -> TextInputConfiguration { TextInputConfiguration::default() } + + /// See [`InputHandler::text_input_editable_range`] for details + fn text_input_editable_range( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } } /// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`] @@ -263,6 +272,15 @@ impl InputHandler for ElementInputHandler { self.view .update(cx, |view, cx| view.text_input_configuration(window, cx)) } + + fn text_input_editable_range( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> Option> { + self.view + .update(cx, |view, cx| view.text_input_editable_range(window, cx)) + } } #[cfg(test)] diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 1f07948..eeba999 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1668,6 +1668,14 @@ impl PlatformInputHandler { ) -> TextInputConfiguration { self.handler.text_input_configuration(window, cx) } + + /// See [`InputHandler::text_input_editable_range`]. + pub fn text_input_editable_range(&mut self) -> Option> { + self.cx + .update(|window, cx| self.handler.text_input_editable_range(window, cx)) + .ok() + .flatten() + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1826,6 +1834,24 @@ pub trait InputHandler: 'static { true } + /// The contiguous range of text, in UTF-16 code units, that platform text + /// input may read and edit around the current selection. + /// + /// Platforms that mirror document text into an IME-editable buffer clamp + /// the mirrored window to this range, so multi-step IME edit gestures + /// (word deletion, autocorrect rewrites, suggestion picks) cannot reach + /// content outside it. The range should contain the current selection; + /// when it cannot (a selection spanning a region boundary), platforms + /// degrade the mirrored IME context rather than widening the range. + /// `None` places no bound. + fn text_input_editable_range( + &mut self, + _window: &mut Window, + _cx: &mut App, + ) -> Option> { + None + } + /// Returns whether printable keys should be routed to the IME before keybinding /// matching when a non-ASCII input source (e.g. Japanese, Korean, Chinese IME) /// is active. This prevents multi-stroke keybindings like `jj` from intercepting From 0560f0cf21b1f70c17747b634bcef6cd79924826 Mon Sep 17 00:00:00 2001 From: John Tur Date: Fri, 28 Aug 2026 03:21:39 +0000 Subject: [PATCH 18/45] Improve Windows shell discovery (#63339) - Simplify PowerShell discovery, and add support for .NET global tool installations. - Fix Git Bash discovery in the case where Zed was launched from within Git Bash. Git Bash prepends its internal `mingw64\bin` directory to `PATH`, so `which git` returns a different `bash` executable that causes the relative path lookup to fail. Release Notes: - N/A --- crates/gpui_util/src/lib.rs | 70 +++++++++++++++-------------- crates/gpui_windows/src/platform.rs | 8 +++- crates/util/src/shell.rs | 41 ++++++++++------- 3 files changed, 69 insertions(+), 50 deletions(-) diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs index 4463712..109ef6f 100644 --- a/crates/gpui_util/src/lib.rs +++ b/crates/gpui_util/src/lib.rs @@ -32,7 +32,7 @@ pub fn new_std_command(program: impl AsRef) -> std::process::Command { } #[cfg(target_os = "windows")] -pub fn get_windows_system_shell() -> String { +pub fn get_powershell() -> Option { use std::path::PathBuf; fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option { @@ -71,7 +71,7 @@ pub fn get_windows_system_shell() -> String { }; let exe_path = entry.path().join("pwsh.exe"); - if exe_path.exists() { + if exe_path.is_file() { Some((version, exe_path)) } else { None @@ -84,41 +84,34 @@ pub fn get_windows_system_shell() -> String { fn find_pwsh_in_msix(find_preview: bool) -> Option { let msix_app_dir = PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps"); - if !msix_app_dir.exists() { - return None; - } - - let prefix = if find_preview { - "Microsoft.PowerShellPreview_" + let package_family_name = if find_preview { + "Microsoft.PowerShellPreview_8wekyb3d8bbwe" } else { - "Microsoft.PowerShell_" + "Microsoft.PowerShell_8wekyb3d8bbwe" }; - msix_app_dir - .read_dir() - .ok()? - .filter_map(|entry| { - let entry = entry.ok()?; - if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) { - return None; - } - - if !entry.file_name().to_string_lossy().starts_with(prefix) { - return None; - } - - let exe_path = entry.path().join("pwsh.exe"); - exe_path.exists().then_some(exe_path) - }) - .next() + let pwsh_exe = msix_app_dir.join(package_family_name).join("pwsh.exe"); + pwsh_exe.exists().then_some(pwsh_exe) } fn find_pwsh_in_scoop() -> Option { let pwsh_exe = PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe"); - pwsh_exe.exists().then_some(pwsh_exe) + pwsh_exe.is_file().then_some(pwsh_exe) + } + + fn find_pwsh_in_dotnet_tools() -> Option { + let pwsh_exe = + PathBuf::from(std::env::var_os("USERPROFILE")?).join(".dotnet\\tools\\pwsh.exe"); + pwsh_exe.is_file().then_some(pwsh_exe) + } + + fn find_windows_powershell() -> Option { + let system_root = PathBuf::from(std::env::var_os("SystemRoot")?); + let powershell = system_root.join("System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + powershell.is_file().then_some(powershell) } - static SYSTEM_SHELL: std::sync::LazyLock = std::sync::LazyLock::new(|| { + static POWERSHELL: std::sync::LazyLock> = std::sync::LazyLock::new(|| { let locations = [ || find_pwsh_in_programfiles(false, false), || find_pwsh_in_programfiles(true, false), @@ -127,8 +120,10 @@ pub fn get_windows_system_shell() -> String { || find_pwsh_in_msix(true), || find_pwsh_in_programfiles(true, true), || find_pwsh_in_scoop(), + || find_pwsh_in_dotnet_tools(), || which::which_global("pwsh.exe").ok(), || which::which_global("powershell.exe").ok(), + || find_windows_powershell(), ]; locations @@ -136,13 +131,22 @@ pub fn get_windows_system_shell() -> String { .find_map(|f| f()) .map(|p| p.to_string_lossy().trim().to_owned()) .inspect(|shell| log::info!("Found powershell in: {}", shell)) - .unwrap_or_else(|| { - log::warn!("Powershell not found, falling back to `cmd`"); - "cmd.exe".to_string() - }) }); - (*SYSTEM_SHELL).clone() + (*POWERSHELL).clone() +} + +#[cfg(target_os = "windows")] +pub fn get_windows_system_shell() -> String { + static CMD: std::sync::LazyLock = std::sync::LazyLock::new(|| { + log::warn!("Powershell not found, falling back to `cmd`"); + let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()); + std::path::PathBuf::from(system_root) + .join("System32\\cmd.exe") + .to_string_lossy() + .into_owned() + }); + get_powershell().unwrap_or_else(|| (*CMD).clone()) } pub fn post_inc + AddAssign + Copy>(value: &mut T) -> T { diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 9147ce5..d0ae3c7 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -12,7 +12,7 @@ use std::{ use anyhow::{Context as _, Result, anyhow}; use futures::channel::oneshot::{self, Receiver}; -use gpui_util::{ResultExt, get_windows_system_shell, new_std_command}; +use gpui_util::{ResultExt, get_powershell, new_std_command}; use itertools::Itertools; use parking_lot::RwLock; use smallvec::SmallVec; @@ -507,9 +507,13 @@ impl Platform for WindowsPlatform { // can pump the Win32 message loop (via `CreateProcessW`), which // re-enters message handling possibly resulting in another mutable // borrow of the `AppCell` ending up with a double borrow panic + let Some(powershell) = get_powershell() else { + log::error!("failed to restart: PowerShell is unavailable"); + return; + }; self.foreground_executor .spawn(async move { - let mut command = new_std_command(get_windows_system_shell()); + let mut command = new_std_command(powershell); let arguments = encode_restart_arguments(&arguments); command .arg("-command") diff --git a/crates/util/src/shell.rs b/crates/util/src/shell.rs index e9b6d05..985d352 100644 --- a/crates/util/src/shell.rs +++ b/crates/util/src/shell.rs @@ -1,6 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::{borrow::Cow, fmt, path::Path, sync::LazyLock}; +use std::{borrow::Cow, fmt, path::Path}; +#[cfg(windows)] +use std::{path::PathBuf, sync::LazyLock}; /// Shell configuration to open the terminal with. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)] @@ -84,33 +86,42 @@ pub fn get_default_system_shell() -> String { /// Get the default system shell, preferring bash on Windows. pub fn get_default_system_shell_preferring_bash() -> String { - if cfg!(windows) { + #[cfg(windows)] + { get_windows_bash().unwrap_or_else(|| get_windows_system_shell()) - } else { + } + + #[cfg(not(windows))] + { "/bin/sh".to_string() } } +#[cfg(windows)] pub fn get_windows_bash() -> Option { - use std::path::PathBuf; - - fn find_bash_in_scoop() -> Option { - let bash_exe = - PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\bash.exe"); - bash_exe.exists().then_some(bash_exe) + fn find_bash_in_installation(install_root: &Path) -> Option { + if !install_root.join("git-bash.exe").is_file() { + return None; + } + let bash = install_root.join("bin").join("bash.exe"); + bash.is_file().then_some(bash) } fn find_bash_in_git() -> Option { - // /path/to/git/cmd/git.exe/../../bin/bash.exe + if let Some(bash) = std::env::var_os("GIT_INSTALL_ROOT") + .map(PathBuf::from) + .and_then(|path| find_bash_in_installation(&path)) + { + return Some(bash); + } let git = which::which("git").ok()?; - let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe"); - git_bash.exists().then_some(git_bash) + let binary_directory = git.parent()?; + let parent = binary_directory.parent()?; + find_bash_in_installation(parent).or_else(|| find_bash_in_installation(parent.parent()?)) } static BASH: LazyLock> = LazyLock::new(|| { - let bash = find_bash_in_scoop() - .or_else(|| find_bash_in_git()) - .map(|p| p.to_string_lossy().into_owned()); + let bash = find_bash_in_git().map(|p| p.to_string_lossy().into_owned()); if let Some(ref path) = bash { log::info!("Found bash at {}", path); } From 612a0764672efe86c0556d1ae633cd564a9dbf25 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Fri, 28 Aug 2026 17:22:25 +0000 Subject: [PATCH 19/45] gpui: Touch events (#63373) Adds support to GPUI for touch events, important for mobile web browsers, and any potential native ios/android platforms --- crates/gpui/src/gestures.rs | 764 +++++++++++++++++++++++++++++++++++- crates/gpui/src/window.rs | 63 ++- 2 files changed, 824 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index 99f0efc..4fd4715 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -10,9 +10,17 @@ //! [`PinchEvent`](crate::PinchEvent)s β€” so components written against //! `on_click` and scroll containers work untouched on mobile. -use std::time::{Duration, Instant}; +use std::collections::VecDeque; +use std::mem; +use std::time::Duration; -use crate::{Axis, IsZero, Pixels, Point, TouchPhase, px}; +use scheduler::Instant; +use smallvec::SmallVec; + +use crate::{ + Axis, IsZero, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Pixels, Point, ScrollDelta, + ScrollWheelEvent, TouchEvent, TouchId, TouchPhase, point, px, +}; const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); @@ -193,6 +201,420 @@ pub struct NullPlatformGestures; impl PlatformGestures for NullPlatformGestures {} +/// Ceiling on recognized fling velocity, in pixels per second (matches +/// Flutter's `kMaxFlingVelocity`). +const MAX_FLING_VELOCITY: f32 = 8000.; + +/// Momentum below this speed, in pixels per second, is imperceptible: the +/// fling stops and the synthetic scroll stream is closed. +const MOMENTUM_STOP_VELOCITY: f32 = 10.; + +/// Upper bound on the time a single momentum tick may integrate, so a stalled +/// frame loop (backgrounded window, long pause) resumes without a huge jump. +/// Must stay well above a plausible worst-case frame interval: clamping a +/// normal slow frame would advance the fling slower than real time, making +/// momentum crawl on exactly the devices that already render slowly. +const MOMENTUM_MAX_TICK: Duration = Duration::from_millis(250); + +/// How far back the release-velocity estimate looks. Samples older than this +/// reflect an earlier part of the gesture, not the speed at release. +const VELOCITY_WINDOW: Duration = Duration::from_millis(100); + +/// A pause between samples longer than this means the finger stopped: +/// anything before the pause describes an earlier motion, not the release +/// (Flutter's `kAssumePointerMoveStoppedMilliseconds`). Touch hardware +/// reports movement every 8–16ms while the finger is in motion. +const VELOCITY_ASSUME_STOPPED_GAP: Duration = Duration::from_millis(40); + +const VELOCITY_MAX_SAMPLES: usize = 20; + +/// The portable recognizer behind raw touch input: it watches the +/// [`TouchEvent`] stream for one touch at a time and resolves it into either +/// a tap or a pan, following the competition model described in the module +/// docs. Pans continue into post-release momentum when the touch lifts at +/// speed; the window drives that phase through [`Self::tick_momentum`]. +/// +/// Taps are currently surfaced as synthesized mouse presses rather than +/// [`ClickEvent::Touch`](crate::ClickEvent), which keeps every existing +/// mouse-driven behavior (click listeners, caret placement, double-tap +/// selection) working before elements grow a direct tap-delivery path. +/// Long-press and pinch recognition are not implemented yet, and additional +/// touches are ignored while one is being recognized. +pub(crate) struct TouchGestureRecognizer { + tuning: GestureTuning, + state: TouchGestureState, + momentum: Option, + last_tap: Option, +} + +/// A semantic event recognized from raw touches, ready to dispatch through +/// the window's existing input paths. +#[derive(Debug)] +pub(crate) enum RecognizedTouchGesture { + /// One step of a pan (or of its post-release momentum), delivered to + /// scroll listeners at the pan's starting position. + Scroll(ScrollWheelEvent), + /// A recognized tap, delivered as a synthesized mouse press and release. + Tap { + down: MouseDownEvent, + up: MouseUpEvent, + }, +} + +enum TouchGestureState { + Idle, + /// The touch is still within `touch_slop` of where it started: it can + /// still resolve into either a tap or a pan. + Pending(ActiveTouch), + /// The touch exceeded `touch_slop`: it is a pan until it ends, and its + /// movement flows out as scroll events. + Panning(ActiveTouch), +} + +struct ActiveTouch { + id: TouchId, + start_position: Point, + last_position: Point, + velocity_tracker: VelocityTracker, +} + +struct CompletedTap { + position: Point, + time: Instant, + count: usize, +} + +struct Momentum { + /// Where the pan started; synthesized scroll events keep hit-testing + /// there so momentum stays with the container the gesture began on. + position: Point, + /// Pixels per second. + velocity: Point, + last_tick: Instant, +} + +impl TouchGestureRecognizer { + pub(crate) fn new(tuning: GestureTuning) -> Self { + Self { + tuning, + state: TouchGestureState::Idle, + momentum: None, + last_tap: None, + } + } + + pub(crate) fn handle_event( + &mut self, + event: &TouchEvent, + ) -> SmallVec<[RecognizedTouchGesture; 2]> { + self.handle_event_at(event, Instant::now()) + } + + fn handle_event_at( + &mut self, + event: &TouchEvent, + now: Instant, + ) -> SmallVec<[RecognizedTouchGesture; 2]> { + let mut recognized = SmallVec::new(); + match event.phase { + TouchPhase::Started => { + if let Some(momentum) = self.momentum.take() { + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + momentum.position, + Point::default(), + TouchPhase::Ended, + ))); + } + if matches!(self.state, TouchGestureState::Idle) { + let mut velocity_tracker = VelocityTracker::default(); + velocity_tracker.push(now, event.position); + self.state = TouchGestureState::Pending(ActiveTouch { + id: event.id, + start_position: event.position, + last_position: event.position, + velocity_tracker, + }); + } + } + TouchPhase::Moved => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending(mut touch) if touch.id == event.id => { + touch.velocity_tracker.push(now, event.position); + let accumulated = event.position - touch.start_position; + if accumulated.magnitude() > f64::from(self.tuning.touch_slop) { + // Carry the full movement so far into the first scroll + // step: the content catches up to the finger instead + // of losing the slop distance. + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + accumulated, + TouchPhase::Started, + ))); + self.state = TouchGestureState::Panning(touch); + } else { + self.state = TouchGestureState::Pending(touch); + } + } + TouchGestureState::Panning(mut touch) if touch.id == event.id => { + touch.velocity_tracker.push(now, event.position); + let delta = event.position - touch.last_position; + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + delta, + TouchPhase::Moved, + ))); + self.state = TouchGestureState::Panning(touch); + } + other => self.state = other, + }, + TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending(touch) if touch.id == event.id => { + let tap_count = match &self.last_tap { + Some(tap) + if now.duration_since(tap.time) <= self.tuning.multi_tap_interval + && (event.position - tap.position).magnitude() + <= f64::from(self.tuning.multi_tap_slop) => + { + tap.count + 1 + } + _ => 1, + }; + self.last_tap = Some(CompletedTap { + position: event.position, + time: now, + count: tap_count, + }); + recognized.push(RecognizedTouchGesture::Tap { + down: MouseDownEvent { + button: MouseButton::Left, + position: event.position, + modifiers: Modifiers::default(), + click_count: tap_count, + first_mouse: false, + }, + up: MouseUpEvent { + button: MouseButton::Left, + position: event.position, + modifiers: Modifiers::default(), + click_count: tap_count, + }, + }); + } + TouchGestureState::Panning(touch) if touch.id == event.id => { + let delta = event.position - touch.last_position; + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + delta, + TouchPhase::Ended, + ))); + // The release deliberately contributes no velocity + // sample: it usually repeats the last movement's position + // with a later timestamp, which would dilute the + // estimate. But a release long after the last movement + // means the finger had already stopped, so nothing + // flings. + let finger_stopped = + touch + .velocity_tracker + .latest_sample_time() + .is_none_or(|latest| { + now.duration_since(latest) > VELOCITY_ASSUME_STOPPED_GAP + }); + let velocity = if finger_stopped { + Point::default() + } else { + touch.velocity_tracker.velocity() + }; + let speed = (velocity.x.powi(2) + velocity.y.powi(2)).sqrt(); + if speed >= self.tuning.min_fling_velocity { + let velocity = if speed > MAX_FLING_VELOCITY { + velocity * (MAX_FLING_VELOCITY / speed) + } else { + velocity + }; + self.momentum = Some(Momentum { + position: touch.start_position, + velocity, + last_tick: now, + }); + } + } + other => self.state = other, + }, + TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending(touch) if touch.id == event.id => {} + TouchGestureState::Panning(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + Point::default(), + TouchPhase::Cancelled, + ))); + } + other => self.state = other, + }, + } + recognized + } + + pub(crate) fn has_momentum(&self) -> bool { + self.momentum.is_some() + } + + /// Advances post-fling momentum by one frame, returning the scroll step + /// to dispatch, or `None` when no momentum is in progress. The final step + /// carries [`TouchPhase::Ended`] to close the synthetic scroll stream. + pub(crate) fn tick_momentum(&mut self) -> Option { + self.tick_momentum_at(Instant::now()) + } + + fn tick_momentum_at(&mut self, now: Instant) -> Option { + let momentum = self.momentum.as_mut()?; + let elapsed = now + .duration_since(momentum.last_tick) + .min(MOMENTUM_MAX_TICK); + momentum.last_tick = now; + let delta = point( + px(momentum.velocity.x * elapsed.as_secs_f32()), + px(momentum.velocity.y * elapsed.as_secs_f32()), + ); + momentum.velocity *= self + .tuning + .momentum_decay_per_ms + .powf(elapsed.as_secs_f32() * 1000.); + let speed = (momentum.velocity.x.powi(2) + momentum.velocity.y.powi(2)).sqrt(); + let position = momentum.position; + if speed < MOMENTUM_STOP_VELOCITY { + self.momentum = None; + Some(RecognizedTouchGesture::Scroll(scroll_event( + position, + delta, + TouchPhase::Ended, + ))) + } else { + Some(RecognizedTouchGesture::Scroll(scroll_event( + position, + delta, + TouchPhase::Moved, + ))) + } + } +} + +fn scroll_event( + position: Point, + delta: Point, + touch_phase: TouchPhase, +) -> ScrollWheelEvent { + ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(delta), + modifiers: Modifiers::default(), + touch_phase, + } +} + +/// Estimates the velocity a touch had at its newest sample. +#[derive(Default)] +struct VelocityTracker { + samples: VecDeque<(Instant, Point)>, +} + +impl VelocityTracker { + fn push(&mut self, time: Instant, position: Point) { + self.samples.push_back((time, position)); + while self.samples.len() > VELOCITY_MAX_SAMPLES { + self.samples.pop_front(); + } + } + + fn latest_sample_time(&self) -> Option { + self.samples.back().map(|(time, _)| *time) + } + + /// The velocity at the newest sample, in pixels per second. + /// + /// Fits a second-degree polynomial by least squares over the trailing + /// [`VELOCITY_WINDOW`] and takes its derivative at the newest sample, + /// like Flutter's `VelocityTracker` and Android's `lsq2` strategy. An + /// endpoint difference over the same window would report the window's + /// *average* speed, which for a flick β€” still accelerating at lift-off β€” + /// is roughly half the speed the finger actually had at release. + fn velocity(&self) -> Point { + let Some((newest_time, _)) = self.samples.back() else { + return Point::default(); + }; + let mut times_seconds: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut horizontal: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut vertical: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut previous_time = *newest_time; + for (time, position) in self.samples.iter().rev() { + let age = newest_time.duration_since(*time); + if age > VELOCITY_WINDOW + || previous_time.duration_since(*time) > VELOCITY_ASSUME_STOPPED_GAP + { + break; + } + previous_time = *time; + times_seconds.push(-age.as_secs_f64()); + horizontal.push(f64::from(f32::from(position.x))); + vertical.push(f64::from(f32::from(position.y))); + } + + let endpoint_estimate = |values: &[f64]| -> f32 { + let elapsed = -times_seconds.last().copied().unwrap_or(0.); + if elapsed <= f64::EPSILON { + return 0.; + } + ((values.first().copied().unwrap_or(0.) - values.last().copied().unwrap_or(0.)) + / elapsed) as f32 + }; + if times_seconds.len() < 3 { + return point(endpoint_estimate(&horizontal), endpoint_estimate(&vertical)); + } + point( + quadratic_velocity_at_newest(×_seconds, &horizontal).map_or_else( + || endpoint_estimate(&horizontal), + |velocity| velocity as f32, + ), + quadratic_velocity_at_newest(×_seconds, &vertical) + .map_or_else(|| endpoint_estimate(&vertical), |velocity| velocity as f32), + ) + } +} + +/// Least-squares fit of `value = a0 + a1Β·t + a2Β·tΒ²` returning `a1`: the +/// fitted curve's velocity at `t = 0`, which callers place at the newest +/// sample. `None` when the samples are too degenerate to fit (all +/// simultaneous, for example). +fn quadratic_velocity_at_newest(times: &[f64], values: &[f64]) -> Option { + let count = times.len() as f64; + let (mut sum_t1, mut sum_t2, mut sum_t3, mut sum_t4) = (0., 0., 0., 0.); + let (mut sum_v, mut sum_vt, mut sum_vt2) = (0., 0., 0.); + for (&time, &value) in times.iter().zip(values) { + let time_squared = time * time; + sum_t1 += time; + sum_t2 += time_squared; + sum_t3 += time_squared * time; + sum_t4 += time_squared * time_squared; + sum_v += value; + sum_vt += value * time; + sum_vt2 += value * time_squared; + } + // Cramer's rule on the 3Γ—3 normal equations, solved for the linear + // coefficient only. + let determinant = count * (sum_t2 * sum_t4 - sum_t3 * sum_t3) + - sum_t1 * (sum_t1 * sum_t4 - sum_t3 * sum_t2) + + sum_t2 * (sum_t1 * sum_t3 - sum_t2 * sum_t2); + if determinant.abs() < 1e-12 { + return None; + } + let linear_determinant = count * (sum_vt * sum_t4 - sum_t3 * sum_vt2) + - sum_v * (sum_t1 * sum_t4 - sum_t3 * sum_t2) + + sum_t2 * (sum_t1 * sum_vt2 - sum_vt * sum_t2); + Some(linear_determinant / determinant) +} + #[cfg(test)] mod tests { use super::*; @@ -309,4 +731,342 @@ mod tests { assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal)); assert_eq!(horizontal_delta, point(px(10.), px(0.))); } + + #[test] + fn touch_within_slop_resolves_to_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + let recognized = + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 10.), now); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 12., 11.), + now + Duration::from_millis(20), + ); + assert!(recognized.is_empty()); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 12., 11.), + now + Duration::from_millis(60), + ); + let [RecognizedTouchGesture::Tap { down, up }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 1); + assert_eq!(down.position, point(px(12.), px(11.))); + assert_eq!(up.click_count, 1); + } + + #[test] + fn consecutive_taps_accumulate_tap_count() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at(&touch_event(TouchId(1), TouchPhase::Started, 10., 10.), now); + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 10., 10.), + now + Duration::from_millis(40), + ); + + let second_down = now + Duration::from_millis(200); + recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 14., 10.), + second_down, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 14., 10.), + second_down + Duration::from_millis(40), + ); + let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 2); + + let late_down = second_down + Duration::from_secs(2); + recognizer.handle_event_at( + &touch_event(TouchId(3), TouchPhase::Started, 14., 10.), + late_down, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(3), TouchPhase::Ended, 14., 10.), + late_down + Duration::from_millis(40), + ); + let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 1); + } + + #[test] + fn touch_beyond_slop_resolves_to_pan() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 120.), + now + Duration::from_millis(16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Started); + assert_eq!(scroll.position, point(px(100.), px(100.))); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(20.))); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 135.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.position, point(px(100.), px(100.))); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(15.))); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 135.), + now + Duration::from_millis(48), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + } + + #[test] + fn fast_release_starts_momentum_that_decays_to_a_stop() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now); + for step in 1..=5 { + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 300. - step as f32 * 20.), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 200.), + now + Duration::from_millis(6 * 16), + ); + assert!(recognizer.has_momentum()); + + let tick = now + Duration::from_millis(6 * 16 + 16); + let recognized = recognizer.tick_momentum_at(tick); + let Some(RecognizedTouchGesture::Scroll(scroll)) = recognized else { + panic!("expected momentum scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.position, point(px(100.), px(300.))); + let delta = scroll.delta.pixel_delta(px(16.)); + assert!( + delta.y < px(0.), + "momentum should continue upward, got {delta:?}" + ); + // The least-squares fit may leave float residue on the motionless axis. + assert!( + delta.x.abs() < px(0.001), + "expected no x motion, got {delta:?}" + ); + + let mut last_phase = TouchPhase::Moved; + let mut ticks = 0; + let mut time = tick; + while recognizer.has_momentum() { + time += Duration::from_millis(16); + ticks += 1; + assert!(ticks < 1000, "momentum never stopped"); + if let Some(RecognizedTouchGesture::Scroll(scroll)) = recognizer.tick_momentum_at(time) + { + last_phase = scroll.touch_phase; + } + } + assert_eq!(last_phase, TouchPhase::Ended); + assert!(recognizer.tick_momentum_at(time).is_none()); + } + + #[test] + fn slow_release_does_not_start_momentum() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 280.), + now + Duration::from_millis(16), + ); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 279.), + now + Duration::from_millis(500), + ); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 279.), + now + Duration::from_millis(600), + ); + assert!(!recognizer.has_momentum()); + } + + #[test] + fn new_touch_interrupts_momentum() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 300.), + now, + ); + for step in 1..=3 { + recognizer.handle_event_at( + &touch_event( + TouchId(1), + TouchPhase::Moved, + 100., + 300. - step as f32 * 33., + ), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 200.), + now + Duration::from_millis(64), + ); + assert!(recognizer.has_momentum()); + + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 100., 200.), + now + Duration::from_millis(200), + ); + assert!(!recognizer.has_momentum()); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected closing scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + assert!(scroll.delta.pixel_delta(px(16.)).is_zero()); + } + + #[test] + fn cancelled_pan_emits_cancelled_scroll_and_no_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 150.), + now + Duration::from_millis(16), + ); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Cancelled, 100., 150.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected cancelled scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Cancelled); + assert!(!recognizer.has_momentum()); + + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Cancelled, 100., 102.), + now + Duration::from_millis(16), + ); + assert!(recognized.is_empty(), "cancelled tap must not click"); + } + + #[test] + fn concurrent_touches_are_ignored_while_one_is_active() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 100.), + now, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 200., 200.), + now + Duration::from_millis(8), + ); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Moved, 200., 300.), + now + Duration::from_millis(16), + ); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 200., 300.), + now + Duration::from_millis(24), + ); + assert!(recognized.is_empty()); + + // The first touch still resolves normally. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Moved, 100., 150.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Started); + } + + #[test] + fn flick_velocity_reflects_release_speed_not_window_average() { + // A uniformly accelerating flick: position grows quadratically, so + // the speed at the newest sample (2Β·kΒ·t) is twice the window + // average (kΒ·t). The estimator must report the former. + let mut velocity_tracker = VelocityTracker::default(); + let start = Instant::now(); + for step in 0..=6 { + let t = step as f32 * 0.016; + velocity_tracker.push( + start + Duration::from_millis(step * 16), + point(px(0.), px(1000. * t * t)), + ); + } + let velocity = velocity_tracker.velocity(); + let release_speed = 2. * 1000. * 0.096; + assert!( + (velocity.y - release_speed).abs() < 1., + "expected β‰ˆ{release_speed} px/s at release, got {} px/s", + velocity.y + ); + assert_eq!(velocity.x, 0.); + } + + #[test] + fn samples_before_a_pause_do_not_contribute_velocity() { + // Fast motion, then a hold longer than the stopped-finger gap, then + // a slow nudge: only the motion after the pause describes the + // release. + let mut velocity_tracker = VelocityTracker::default(); + let start = Instant::now(); + velocity_tracker.push(start, point(px(0.), px(0.))); + velocity_tracker.push(start + Duration::from_millis(16), point(px(0.), px(50.))); + velocity_tracker.push(start + Duration::from_millis(80), point(px(0.), px(52.))); + velocity_tracker.push(start + Duration::from_millis(96), point(px(0.), px(54.))); + let velocity = velocity_tracker.velocity(); + assert!( + velocity.y < 200., + "pre-pause motion leaked into the estimate: {} px/s", + velocity.y + ); + } + + fn touch_event(id: TouchId, phase: TouchPhase, x: f32, y: f32) -> TouchEvent { + TouchEvent { + id, + phase, + position: point(px(x), px(y)), + force: None, + } + } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2c70058..085a020 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -29,6 +29,8 @@ use crate::{ /// A gaussian is cut off after three standard deviations. const BLUR_REACH: f32 = 3.; +use crate::gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer}; +use crate::interactive::TouchEvent; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; #[cfg(target_os = "macos")] @@ -1200,6 +1202,7 @@ pub struct Window { #[cfg(feature = "profiler")] window_profiler: profiler::WindowProfiler, last_input_modality: InputModality, + touch_gestures: TouchGestureRecognizer, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -1891,6 +1894,11 @@ impl Window { #[cfg(feature = "profiler")] window_profiler: profiler::WindowProfiler::new(handle.window_id())?, last_input_modality: InputModality::Mouse, + touch_gestures: TouchGestureRecognizer::new( + cx.platform + .gestures() + .map_or_else(GestureTuning::default, |gestures| gestures.tuning()), + ), refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -5240,7 +5248,7 @@ impl Window { .unwrap_or_else(|| action.name().to_string()) } - /// Dispatch a mouse or keyboard event on the window. + /// Dispatch a mouse, keyboard, or touch event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { #[cfg(feature = "profiler")] @@ -5365,6 +5373,8 @@ impl Window { self.dispatch_mouse_event(any_mouse_event, cx); } else if let Some(any_key_event) = event.keyboard_event() { self.dispatch_key_event(any_key_event, cx); + } else if let Some(touch_event) = event.touch_event() { + self.dispatch_touch_event(touch_event, cx); } // Must run after the move is dispatched: the platform owns the gesture afterwards, so this @@ -5414,6 +5424,57 @@ impl Window { } } + /// Runs the portable gesture recognizer over a raw touch event and + /// dispatches whatever it resolves (scroll steps, synthesized taps) + /// through the ordinary mouse-event path. + fn dispatch_touch_event(&mut self, event: &TouchEvent, cx: &mut App) { + let recognized_gestures = self.touch_gestures.handle_event(event); + let mut tapped = false; + for gesture in recognized_gestures { + tapped |= matches!(gesture, RecognizedTouchGesture::Tap { .. }); + self.dispatch_recognized_touch_gesture(gesture, cx); + } + // The platform's touch-release handler may inspect the input handler + // as soon as this dispatch returns (the web platform decides virtual + // keyboard visibility there, inside the user gesture). Input handlers + // are registered during draw, so draw now to make them reflect any + // focus change the tap just caused. + if tapped && self.invalidator.is_dirty() { + self.draw(cx).clear(cx); + } + if self.touch_gestures.has_momentum() { + self.schedule_touch_momentum_tick(); + } + } + + fn dispatch_recognized_touch_gesture(&mut self, gesture: RecognizedTouchGesture, cx: &mut App) { + match gesture { + RecognizedTouchGesture::Scroll(scroll_wheel) => { + self.mouse_position = scroll_wheel.position; + cx.propagate_event = true; + self.dispatch_mouse_event(&scroll_wheel, cx); + } + RecognizedTouchGesture::Tap { down, up } => { + self.mouse_position = up.position; + cx.propagate_event = true; + self.dispatch_mouse_event(&down, cx); + cx.propagate_event = true; + self.dispatch_mouse_event(&up, cx); + } + } + } + + fn schedule_touch_momentum_tick(&mut self) { + self.on_next_frame(|window, cx| { + if let Some(gesture) = window.touch_gestures.tick_momentum() { + window.dispatch_recognized_touch_gesture(gesture, cx); + } + if window.touch_gestures.has_momentum() { + window.schedule_touch_momentum_tick(); + } + }); + } + fn dispatch_mouse_event(&mut self, event: &dyn Any, cx: &mut App) { let hit_test = self.rendered_frame.hit_test(self.mouse_position()); if hit_test != self.mouse_hit_test { From 98a5a5151d07fc40acbf9ae85685417c3abd239b Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 31 Aug 2026 13:07:22 +0000 Subject: [PATCH 20/45] gpui_web: Let applications provide fonts (#63493) `gpui_web` currently embeds IBM Plex Sans and Lilex into every browser application during platform construction. Applications such as Delta already provide their own fonts, so those binaries contain overlapping font bundles and register the same faces twice. This change starts the web platform with an empty font database, matching the ownership boundary used by native applications: the application selects and registers its fonts before opening a window. Delta already follows that sequence. GPUI's browser-capable examples now load the former eight-face bundle explicitly through shared example support, while the standalone `hello_web` example demonstrates registering a minimal application font directly. Testing performed: - `cargo check -p gpui --examples --target wasm32-unknown-unknown` - `cargo check -p gpui --examples` - `cargo check -p gpui_web --target wasm32-unknown-unknown` - `cargo check --manifest-path crates/gpui_web/examples/hello_web/Cargo.toml --target wasm32-unknown-unknown` - `./script/clippy -p gpui_web --target wasm32-unknown-unknown` - `cargo fmt -p gpui -p gpui_web --check` Release Notes: - N/A --- crates/gpui/Cargo.toml | 4 ++ crates/gpui/examples/a11y.rs | 6 +++ crates/gpui/examples/anchor.rs | 6 +++ crates/gpui/examples/animation.rs | 6 +++ crates/gpui/examples/data_table.rs | 6 +++ crates/gpui/examples/drag_drop.rs | 6 +++ crates/gpui/examples/example_support/fonts.rs | 44 +++++++++++++++++++ crates/gpui/examples/focus_visible.rs | 6 +++ crates/gpui/examples/gif_viewer.rs | 6 +++ crates/gpui/examples/gradient.rs | 6 +++ crates/gpui/examples/grid_layout.rs | 6 +++ crates/gpui/examples/hello_world.rs | 6 +++ crates/gpui/examples/image/image.rs | 6 +++ crates/gpui/examples/image_gallery.rs | 6 +++ crates/gpui/examples/image_loading.rs | 6 +++ crates/gpui/examples/input.rs | 6 +++ crates/gpui/examples/list_example.rs | 6 +++ crates/gpui/examples/mouse_pressure.rs | 6 +++ .../examples/move_entity_between_windows.rs | 6 +++ crates/gpui/examples/on_window_close_quit.rs | 6 +++ crates/gpui/examples/opacity.rs | 6 +++ crates/gpui/examples/ownership_post.rs | 6 +++ crates/gpui/examples/painting.rs | 6 +++ crates/gpui/examples/paths_bench.rs | 6 +++ crates/gpui/examples/pattern.rs | 6 +++ crates/gpui/examples/popover.rs | 6 +++ crates/gpui/examples/scrollable.rs | 6 +++ crates/gpui/examples/set_menus.rs | 6 +++ crates/gpui/examples/shadow.rs | 6 +++ crates/gpui/examples/svg/svg.rs | 6 +++ crates/gpui/examples/system_notifications.rs | 6 +++ crates/gpui/examples/tab_stop.rs | 6 +++ crates/gpui/examples/testing.rs | 7 +++ crates/gpui/examples/text.rs | 6 +++ crates/gpui/examples/text_layout.rs | 6 +++ crates/gpui/examples/text_wrapper.rs | 6 +++ crates/gpui/examples/tree.rs | 7 +++ crates/gpui/examples/uniform_list.rs | 6 +++ .../view_example/view_example_main.rs | 6 +++ crates/gpui/examples/window.rs | 6 +++ crates/gpui/examples/window_movable.rs | 6 +++ crates/gpui/examples/window_positioning.rs | 6 +++ crates/gpui/examples/window_shadow.rs | 6 +++ 43 files changed, 296 insertions(+) create mode 100644 crates/gpui/examples/example_support/fonts.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 6abaa9a..a99c3cb 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -146,6 +146,10 @@ unicode-segmentation = { workspace = true } http_client = { workspace = true, features = ["test-support"] } proptest = { workspace = true } +[target.'cfg(target_family = "wasm")'.dev-dependencies] +wasm-bindgen = { workspace = true } +web-sys = { version = "0.3", features = ["console"] } + [build-dependencies] embed-resource = { version = "3.0", optional = true } diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs index 6e37354..ff389f3 100644 --- a/crates/gpui/examples/a11y.rs +++ b/crates/gpui/examples/a11y.rs @@ -30,6 +30,9 @@ //! - "2. Run tests" //! - "3. Ship it" +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ AccessibleAction, App, Bounds, Context, FocusHandle, KeyBinding, Role, SharedString, Toggled, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, text, @@ -226,6 +229,9 @@ impl Render for A11yDemo { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.bind_keys([ KeyBinding::new("tab", Tab, None), KeyBinding::new("shift-tab", TabPrev, None), diff --git a/crates/gpui/examples/anchor.rs b/crates/gpui/examples/anchor.rs index 7aa9bb4..b4b280b 100644 --- a/crates/gpui/examples/anchor.rs +++ b/crates/gpui/examples/anchor.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ Anchor, AnchoredPositionMode, App, Axis, Bounds, Context, Half as _, InteractiveElement, ParentElement, Pixels, Point, Render, SharedString, Size, Window, WindowBounds, WindowOptions, @@ -168,6 +171,9 @@ impl Render for AnchorDemo { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( diff --git a/crates/gpui/examples/animation.rs b/crates/gpui/examples/animation.rs index 4e09d15..e055b7d 100644 --- a/crates/gpui/examples/animation.rs +++ b/crates/gpui/examples/animation.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::time::Duration; use anyhow::Result; @@ -306,6 +309,9 @@ impl Render for AnimationExample { fn run_example() { application().with_assets(Assets {}).run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( None, diff --git a/crates/gpui/examples/data_table.rs b/crates/gpui/examples/data_table.rs index b3f8737..2bee476 100644 --- a/crates/gpui/examples/data_table.rs +++ b/crates/gpui/examples/data_table.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::{ops::Range, rc::Rc, time::Duration}; use gpui::{ @@ -451,6 +454,9 @@ impl Render for DataTable { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { focus: true, diff --git a/crates/gpui/examples/drag_drop.rs b/crates/gpui/examples/drag_drop.rs index b233bc4..8f038ef 100644 --- a/crates/gpui/examples/drag_drop.rs +++ b/crates/gpui/examples/drag_drop.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Half, Hsla, Pixels, Point, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -125,6 +128,9 @@ impl Render for DragDrop { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/example_support/fonts.rs b/crates/gpui/examples/example_support/fonts.rs new file mode 100644 index 0000000..39c3be1 --- /dev/null +++ b/crates/gpui/examples/example_support/fonts.rs @@ -0,0 +1,44 @@ +#[cfg(target_family = "wasm")] +use std::borrow::Cow; + +use gpui::App; + +#[cfg(target_family = "wasm")] +pub fn load_fonts(cx: &App) -> bool { + let fonts = [ + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf") + .as_slice(), + ), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf") + .as_slice(), + ), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf") + .as_slice(), + ), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBoldItalic.ttf") + .as_slice(), + ), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/lilex/Lilex-Regular.ttf").as_slice(), + ), + Cow::Borrowed(include_bytes!("../../../../assets/fonts/lilex/Lilex-Bold.ttf").as_slice()), + Cow::Borrowed(include_bytes!("../../../../assets/fonts/lilex/Lilex-Italic.ttf").as_slice()), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/lilex/Lilex-BoldItalic.ttf").as_slice(), + ), + ]; + if let Err(error) = cx.text_system().add_fonts(fonts.into()) { + web_sys::console::error_1(&format!("failed to load application fonts: {error:#}").into()); + return false; + } + true +} + +#[cfg(not(target_family = "wasm"))] +pub fn load_fonts(_cx: &App) -> bool { + true +} diff --git a/crates/gpui/examples/focus_visible.rs b/crates/gpui/examples/focus_visible.rs index 02a171d..c5b3d5d 100644 --- a/crates/gpui/examples/focus_visible.rs +++ b/crates/gpui/examples/focus_visible.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, @@ -196,6 +199,9 @@ impl Render for Example { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.bind_keys([ KeyBinding::new("tab", Tab, None), KeyBinding::new("shift-tab", TabPrev, None), diff --git a/crates/gpui/examples/gif_viewer.rs b/crates/gpui/examples/gif_viewer.rs index 59fb8d3..80b56aa 100644 --- a/crates/gpui/examples/gif_viewer.rs +++ b/crates/gpui/examples/gif_viewer.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{App, Context, Render, Window, WindowOptions, div, img, prelude::*}; use gpui_platform::application; use std::path::PathBuf; @@ -27,6 +30,9 @@ impl Render for GifViewer { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let gif_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/image/black-cat-typing.gif"); diff --git a/crates/gpui/examples/gradient.rs b/crates/gpui/examples/gradient.rs index 97321f1..df1398f 100644 --- a/crates/gpui/examples/gradient.rs +++ b/crates/gpui/examples/gradient.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, ColorSpace, Context, Half, Render, Window, WindowOptions, canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, size, @@ -247,6 +250,9 @@ impl Render for GradientViewer { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { focus: true, diff --git a/crates/gpui/examples/grid_layout.rs b/crates/gpui/examples/grid_layout.rs index 9c6d723..51f9b13 100644 --- a/crates/gpui/examples/grid_layout.rs +++ b/crates/gpui/examples/grid_layout.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, container_query, div, prelude::*, px, rgb, size, @@ -63,6 +66,9 @@ impl Render for HolyGrailExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/hello_world.rs b/crates/gpui/examples/hello_world.rs index aa67ada..d18cb04 100644 --- a/crates/gpui/examples/hello_world.rs +++ b/crates/gpui/examples/hello_world.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -91,6 +94,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/image/image.rs b/crates/gpui/examples/image/image.rs index 7e67d04..447e79d 100644 --- a/crates/gpui/examples/image/image.rs +++ b/crates/gpui/examples/image/image.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "../example_support/fonts.rs"] +mod example_support; + use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -157,6 +160,9 @@ fn run_example() { base: manifest_dir.join("examples"), }) .run(move |cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.activate(true); cx.on_action(|_: &Quit, cx| cx.quit()); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs index 76f6ae6..8473a1a 100644 --- a/crates/gpui/examples/image_gallery.rs +++ b/crates/gpui/examples/image_gallery.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use futures::FutureExt; use gpui::{ App, AppContext, Asset as _, AssetLogger, Bounds, ClickEvent, Context, ElementId, Entity, @@ -252,6 +255,9 @@ fn run_example() { let app = gpui_platform::single_threaded_web(); app.run(move |cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.activate(true); cx.on_action(|_: &Quit, cx| cx.quit()); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); diff --git a/crates/gpui/examples/image_loading.rs b/crates/gpui/examples/image_loading.rs index c2aab95..b682891 100644 --- a/crates/gpui/examples/image_loading.rs +++ b/crates/gpui/examples/image_loading.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::{path::Path, sync::Arc, time::Duration}; use gpui::{ @@ -196,6 +199,9 @@ impl Render for ImageLoadingExample { fn run_example() { application().with_assets(Assets {}).run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( None, diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs index 370e27d..eadc2d2 100644 --- a/crates/gpui/examples/input.rs +++ b/crates/gpui/examples/input.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::ops::Range; use gpui::{ @@ -696,6 +699,9 @@ impl Render for InputExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.bind_keys([ KeyBinding::new("backspace", Backspace, None), diff --git a/crates/gpui/examples/list_example.rs b/crates/gpui/examples/list_example.rs index 7aeff7c..542b7e3 100644 --- a/crates/gpui/examples/list_example.rs +++ b/crates/gpui/examples/list_example.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, ListAlignment, ListState, Render, Window, WindowBounds, WindowOptions, div, list, prelude::*, px, rgb, size, @@ -143,6 +146,9 @@ impl Render for BottomListDemo { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/mouse_pressure.rs b/crates/gpui/examples/mouse_pressure.rs index d3519b2..3470bf1 100644 --- a/crates/gpui/examples/mouse_pressure.rs +++ b/crates/gpui/examples/mouse_pressure.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, MousePressureEvent, PressureStage, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -48,6 +51,9 @@ impl MousePressureExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( diff --git a/crates/gpui/examples/move_entity_between_windows.rs b/crates/gpui/examples/move_entity_between_windows.rs index eaefabf..e0f1646 100644 --- a/crates/gpui/examples/move_entity_between_windows.rs +++ b/crates/gpui/examples/move_entity_between_windows.rs @@ -8,6 +8,9 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::time::Duration; use gpui::{ @@ -128,6 +131,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/on_window_close_quit.rs b/crates/gpui/examples/on_window_close_quit.rs index 347401c..d61e23e 100644 --- a/crates/gpui/examples/on_window_close_quit.rs +++ b/crates/gpui/examples/on_window_close_quit.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, FocusHandle, KeyBinding, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, @@ -39,6 +42,9 @@ impl Render for ExampleWindow { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let mut bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.bind_keys([KeyBinding::new("cmd-w", CloseWindow, None)]); diff --git a/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index 413c40c..c2af342 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::{fs, path::PathBuf}; use anyhow::Result; @@ -163,6 +166,9 @@ fn run_example() { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), }) .run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/ownership_post.rs b/crates/gpui/examples/ownership_post.rs index a4421b9..04a0b6f 100644 --- a/crates/gpui/examples/ownership_post.rs +++ b/crates/gpui/examples/ownership_post.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{App, Context, Entity, EventEmitter, prelude::*}; use gpui_platform::application; @@ -15,6 +18,9 @@ impl EventEmitter for Counter {} fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let counter: Entity = cx.new(|_cx| Counter { count: 0 }); let subscriber = cx.new(|cx: &mut Context| { cx.subscribe(&counter, |subscriber, _emitter, event, _cx| { diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index 41cbcb2..3d89b0f 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder, PathStyle, Pixels, Point, Render, StrokeOptions, Window, WindowOptions, canvas, div, linear_color_stop, @@ -443,6 +446,9 @@ impl Render for PaintingViewer { fn run_example() { application().run(|cx| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { focus: true, diff --git a/crates/gpui/examples/paths_bench.rs b/crates/gpui/examples/paths_bench.rs index 4e12f1e..236e70e 100644 --- a/crates/gpui/examples/paths_bench.rs +++ b/crates/gpui/examples/paths_bench.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ Background, Bounds, ColorSpace, Context, Path, PathBuilder, Pixels, Render, TitlebarOptions, Window, WindowBounds, WindowOptions, canvas, div, linear_color_stop, linear_gradient, point, @@ -73,6 +76,9 @@ impl Render for PaintingViewer { fn run_example() { application().run(|cx| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { titlebar: Some(TitlebarOptions { diff --git a/crates/gpui/examples/pattern.rs b/crates/gpui/examples/pattern.rs index 3113d39..7d2e98d 100644 --- a/crates/gpui/examples/pattern.rs +++ b/crates/gpui/examples/pattern.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, AppContext, Bounds, Context, Window, WindowBounds, WindowOptions, div, linear_color_stop, linear_gradient, pattern_slash, prelude::*, px, rgb, size, @@ -103,6 +106,9 @@ impl Render for PatternExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/popover.rs b/crates/gpui/examples/popover.rs index e4f0ac0..7678a6a 100644 --- a/crates/gpui/examples/popover.rs +++ b/crates/gpui/examples/popover.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ Anchor, App, Context, Div, Hsla, Stateful, Window, WindowOptions, anchored, deferred, div, prelude::*, px, @@ -167,6 +170,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window(WindowOptions::default(), |_, cx| { cx.new(|_| HelloWorld { open: false, diff --git a/crates/gpui/examples/scrollable.rs b/crates/gpui/examples/scrollable.rs index 39864c8..c817956 100644 --- a/crates/gpui/examples/scrollable.rs +++ b/crates/gpui/examples/scrollable.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, size}; use gpui_platform::application; @@ -46,6 +49,9 @@ impl Render for Scrollable { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/set_menus.rs b/crates/gpui/examples/set_menus.rs index a07f3c3..508a60e 100644 --- a/crates/gpui/examples/set_menus.rs +++ b/crates/gpui/examples/set_menus.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Context, Global, Menu, MenuItem, SharedString, SystemMenuType, Window, WindowOptions, actions, div, prelude::*, @@ -24,6 +27,9 @@ impl Render for SetMenus { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.set_global(AppState::new()); // Bring the menu bar to the foreground (so you can see the menu bar) diff --git a/crates/gpui/examples/shadow.rs b/crates/gpui/examples/shadow.rs index 375ccac..2d8dc09 100644 --- a/crates/gpui/examples/shadow.rs +++ b/crates/gpui/examples/shadow.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds, WindowOptions, div, hsla, prelude::*, px, relative, rgb, size, @@ -589,6 +592,9 @@ impl Render for Shadow { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(1000.0), px(800.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/svg/svg.rs b/crates/gpui/examples/svg/svg.rs index e9d2341..c5ac9b4 100644 --- a/crates/gpui/examples/svg/svg.rs +++ b/crates/gpui/examples/svg/svg.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "../example_support/fonts.rs"] +mod example_support; + use std::fs; use std::path::PathBuf; @@ -76,6 +79,9 @@ fn run_example() { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), }) .run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/system_notifications.rs b/crates/gpui/examples/system_notifications.rs index 70071dc..faf6919 100644 --- a/crates/gpui/examples/system_notifications.rs +++ b/crates/gpui/examples/system_notifications.rs @@ -2,6 +2,9 @@ //! Demonstrates posting, replacing, dismissing, and responding to system notifications. +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Div, SharedString, Stateful, SystemNotification, SystemNotificationAction, SystemNotificationResponse, Window, WindowBounds, WindowOptions, div, @@ -97,6 +100,9 @@ fn button(id: &'static str, label: &'static str) -> Stateful
{ fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.set_app_identity("dev.zed.gpui.system-notifications", "GPUI Notifications"); let view = cx.new(|_| SystemNotificationExample { diff --git a/crates/gpui/examples/tab_stop.rs b/crates/gpui/examples/tab_stop.rs index 3fec59a..cc7c34a 100644 --- a/crates/gpui/examples/tab_stop.rs +++ b/crates/gpui/examples/tab_stop.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, @@ -182,6 +185,9 @@ impl Render for Example { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.bind_keys([ KeyBinding::new("tab", Tab, None), KeyBinding::new("shift-tab", TabPrev, None), diff --git a/crates/gpui/examples/testing.rs b/crates/gpui/examples/testing.rs index f6e1579..76e3b77 100644 --- a/crates/gpui/examples/testing.rs +++ b/crates/gpui/examples/testing.rs @@ -1,4 +1,5 @@ #![cfg_attr(target_family = "wasm", no_main)] + //! Example demonstrating GPUI's testing infrastructure. //! //! When run normally, this displays an interactive counter window. @@ -7,6 +8,9 @@ //! Run the app: cargo run -p gpui --example testing //! Run tests: cargo test -p gpui --example testing --features test-support +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, FocusHandle, Focusable, Render, Task, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, @@ -179,6 +183,9 @@ impl Render for Counter { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.bind_keys([ gpui::KeyBinding::new("up", Increment, Some("Counter")), gpui::KeyBinding::new("down", Decrement, Some("Counter")), diff --git a/crates/gpui/examples/text.rs b/crates/gpui/examples/text.rs index 418ebaa..a1244eb 100644 --- a/crates/gpui/examples/text.rs +++ b/crates/gpui/examples/text.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::{ borrow::Cow, ops::{Deref, DerefMut}, @@ -348,6 +351,9 @@ impl Render for TextExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.set_menus(vec![Menu { name: "GPUI Typography".into(), disabled: false, diff --git a/crates/gpui/examples/text_layout.rs b/crates/gpui/examples/text_layout.rs index 4bb930e..07f560a 100644 --- a/crates/gpui/examples/text_layout.rs +++ b/crates/gpui/examples/text_layout.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, FontStyle, FontWeight, StyledText, Window, WindowBounds, WindowOptions, div, prelude::*, px, size, @@ -85,6 +88,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/text_wrapper.rs b/crates/gpui/examples/text_wrapper.rs index 3750c3e..51e58fc 100644 --- a/crates/gpui/examples/text_wrapper.rs +++ b/crates/gpui/examples/text_wrapper.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, TextOverflow, Window, WindowBounds, WindowOptions, div, prelude::*, px, size, @@ -112,6 +115,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/tree.rs b/crates/gpui/examples/tree.rs index 9c4ea2c..891be47 100644 --- a/crates/gpui/examples/tree.rs +++ b/crates/gpui/examples/tree.rs @@ -1,6 +1,10 @@ #![cfg_attr(target_family = "wasm", no_main)] + //! Renders a div with deep children hierarchy. This example is useful to exemplify that Zed can //! handle deep hierarchies (even though it cannot just yet!). +#[path = "example_support/fonts.rs"] +mod example_support; + use std::sync::LazyLock; use gpui::{App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, size}; @@ -32,6 +36,9 @@ impl Render for Tree { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/uniform_list.rs b/crates/gpui/examples/uniform_list.rs index fabcde5..4a2c478 100644 --- a/crates/gpui/examples/uniform_list.rs +++ b/crates/gpui/examples/uniform_list.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, uniform_list, @@ -40,6 +43,9 @@ impl Render for UniformListExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/view_example/view_example_main.rs b/crates/gpui/examples/view_example/view_example_main.rs index 0eac849..d2e4991 100644 --- a/crates/gpui/examples/view_example/view_example_main.rs +++ b/crates/gpui/examples/view_example/view_example_main.rs @@ -13,6 +13,9 @@ //! //! Run: `cargo run -p gpui --example view_example` +#[path = "../example_support/fonts.rs"] +mod example_support; + mod example_editor; mod example_input; mod example_text_area; @@ -134,6 +137,9 @@ fn section(title: &str) -> Div { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx); cx.bind_keys([ KeyBinding::new("backspace", Backspace, None), diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index 959ea49..51dbe72 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Window, WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, @@ -310,6 +313,9 @@ actions!(window, [Quit]); fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( diff --git a/crates/gpui/examples/window_movable.rs b/crates/gpui/examples/window_movable.rs index 587a2db..b5fb260 100644 --- a/crates/gpui/examples/window_movable.rs +++ b/crates/gpui/examples/window_movable.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, FocusHandle, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -78,6 +81,9 @@ fn open_test_window( fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let window_size = size(px(420.), px(280.0)); let base = Bounds::centered(None, window_size, cx); diff --git a/crates/gpui/examples/window_positioning.rs b/crates/gpui/examples/window_positioning.rs index 036a2fc..22c9b35 100644 --- a/crates/gpui/examples/window_positioning.rs +++ b/crates/gpui/examples/window_positioning.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, DisplayId, Hsla, Pixels, SharedString, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowKind, WindowOptions, div, point, prelude::*, @@ -72,6 +75,9 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds) -> Window fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } // Create several new windows, positioned in the top right corner of each screen let size = Size { width: px(350.), diff --git a/crates/gpui/examples/window_shadow.rs b/crates/gpui/examples/window_shadow.rs index f4a55b4..fba5e91 100644 --- a/crates/gpui/examples/window_shadow.rs +++ b/crates/gpui/examples/window_shadow.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, CursorStyle, Decorations, HitboxBehavior, Hsla, MouseButton, Pixels, Point, ResizeEdge, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowDecorations, @@ -211,6 +214,9 @@ fn resize_edge(pos: Point, shadow_size: Pixels, size: Size) -> O fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx); cx.open_window( WindowOptions { From 2fecec7535e7e80cba0ae38c4189cbb5f730551f Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 31 Aug 2026 13:35:07 +0000 Subject: [PATCH 21/45] gpui: Gate blocking executor APIs on WebAssembly (#63484) GPUI's scheduler exposed synchronous blocking APIs on WebAssembly even though the platform implementation could only panic. This became an implicit crash when cancellation dropped a scoped background operation: preserving the borrowed task lifetime called `Scheduler::block`, which panicked with `Cannot block on wasm`. This change removes blocking scheduler and executor APIs from WebAssembly builds, including borrowed background scopes and the separate `pollster::block_on` export. Native behavior remains unchanged. Application shutdown now runs WebAssembly quit handlers asynchronously as best-effort cleanup rather than blocking the browser event-loop thread. Release Notes: - Fixed a GPUI Web crash caused by synchronous executor blocking. --- crates/gpui/src/app.rs | 13 ++- crates/gpui/src/executor.rs | 27 ++++- crates/gpui/src/gpui.rs | 1 + crates/gpui/src/platform_scheduler.rs | 67 +++++------ crates/scheduler/src/executor.rs | 2 + crates/scheduler/src/scheduler.rs | 1 + crates/scheduler/src/test_scheduler.rs | 147 ++++++++++++++----------- 7 files changed, 145 insertions(+), 113 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 80e9f73..9811fc3 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -73,7 +73,8 @@ mod test_context; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] mod visual_test_context; -/// The duration for which futures returned from [Context::on_app_quit] can run before the application fully quits. +/// The duration for which native applications wait for futures returned from +/// [Context::on_app_quit] before fully quitting. pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(200); /// Temporary(?) wrapper around [`RefCell`] to help us debug any double borrows. @@ -969,8 +970,11 @@ impl App { self.entities.assert_no_new_leaks(snapshot) } - /// Quit the application gracefully. Handlers registered with [`Context::on_app_quit`] - /// will be given `SHUTDOWN_TIMEOUT` to complete before exiting. + /// Quit the application gracefully. + /// + /// Native applications give handlers registered with [`Context::on_app_quit`] + /// [`SHUTDOWN_TIMEOUT`] to complete. WebAssembly runs them asynchronously as best-effort cleanup + /// because its event-loop thread cannot block. pub fn shutdown(&mut self) { let mut futures = Vec::new(); @@ -984,6 +988,7 @@ impl App { self.quitting = true; let futures = futures::future::join_all(futures); + #[cfg(not(target_family = "wasm"))] if self .foreground_executor .block_with_timeout(SHUTDOWN_TIMEOUT, futures) @@ -991,6 +996,8 @@ impl App { { log::error!("timed out waiting on app_will_quit"); } + #[cfg(target_family = "wasm")] + self.foreground_executor.spawn(futures).detach(); self.quitting = false; } diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index fb8eefd..444bb07 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,10 +1,13 @@ use crate::{App, PlatformDispatcher, PlatformScheduler}; +#[cfg(not(target_family = "wasm"))] use futures::channel::mpsc; use futures::prelude::*; use gpui_util::{TryFutureExt, TryFutureExtBacktrace}; use scheduler::Instant; use scheduler::Scheduler; -use std::{future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, time::Duration}; +use std::{future::Future, marker::PhantomData, rc::Rc, sync::Arc, time::Duration}; +#[cfg(not(target_family = "wasm"))] +use std::{mem, pin::Pin}; pub use scheduler::{ DedicatedExecutor, FallibleTask, LocalExecutor as SchedulerLocalExecutor, Priority, Task, @@ -138,8 +141,11 @@ impl BackgroundExecutor { } } - /// Scoped lets you start a number of tasks and waits - /// for all of them to complete before returning. + /// Runs background tasks that may borrow from their environment and waits for all of them to complete. + /// + /// Dropping the returned future cancels its tasks and synchronously waits for their futures to + /// be destroyed before returning. + #[cfg(not(target_family = "wasm"))] pub async fn scoped<'scope, F>(&self, scheduler: F) where F: FnOnce(&mut Scope<'scope>), @@ -155,8 +161,12 @@ impl BackgroundExecutor { } } - /// Scoped lets you start a number of tasks and waits - /// for all of them to complete before returning. + /// Runs prioritized background tasks that may borrow from their environment and waits for all + /// of them to complete. + /// + /// Dropping the returned future cancels its tasks and synchronously waits for their futures to + /// be destroyed before returning. + #[cfg(not(target_family = "wasm"))] pub async fn scoped_priority<'scope, F>(&self, priority: Priority, scheduler: F) where F: FnOnce(&mut Scope<'scope>), @@ -408,7 +418,7 @@ impl ForegroundExecutor { } /// Used by the test harness to run an async test in a synchronous fashion. - #[cfg(any(test, feature = "test-support"))] + #[cfg(all(not(target_family = "wasm"), any(test, feature = "test-support")))] #[track_caller] pub fn block_test(&self, future: impl Future) -> R { use std::cell::Cell; @@ -431,11 +441,13 @@ impl ForegroundExecutor { /// Block the current thread until the given future resolves. /// Consider using `block_with_timeout` instead. + #[cfg(not(target_family = "wasm"))] pub fn block_on(&self, future: impl Future) -> R { self.inner.block_on(future) } /// Block the current thread until the given future resolves or the timeout elapses. + #[cfg(not(target_family = "wasm"))] pub fn block_with_timeout>( &self, duration: Duration, @@ -456,6 +468,7 @@ impl ForegroundExecutor { } /// Scope manages a set of tasks that are enqueued and waited on together. See [`BackgroundExecutor::scoped`]. +#[cfg(not(target_family = "wasm"))] pub struct Scope<'a> { executor: BackgroundExecutor, priority: Priority, @@ -465,6 +478,7 @@ pub struct Scope<'a> { lifetime: PhantomData<&'a ()>, } +#[cfg(not(target_family = "wasm"))] impl<'a> Scope<'a> { fn new(executor: BackgroundExecutor, priority: Priority) -> Self { let (tx, rx) = mpsc::channel(1); @@ -506,6 +520,7 @@ impl<'a> Scope<'a> { } } +#[cfg(not(target_family = "wasm"))] impl Drop for Scope<'_> { fn drop(&mut self) { self.tx.take().unwrap(); diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 41e3c47..b770281 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -166,6 +166,7 @@ pub use util::{FutureExt, Timeout}; pub use view::*; pub use window::*; +#[cfg(not(target_family = "wasm"))] pub use pollster::block_on; /// The context trait, allows the different contexts in GPUI to be used diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index 311f452..a555a5a 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -66,51 +66,42 @@ impl PlatformScheduler { } impl Scheduler for PlatformScheduler { + #[cfg(not(target_family = "wasm"))] fn block( &self, _session_id: Option, - #[cfg_attr(target_family = "wasm", allow(unused_mut))] mut future: Pin< - &mut dyn Future, - >, - #[cfg_attr(target_family = "wasm", allow(unused_variables))] timeout: Option, + mut future: Pin<&mut dyn Future>, + timeout: Option, ) -> bool { - #[cfg(target_family = "wasm")] - { - let _ = (&future, &timeout); - panic!("Cannot block on wasm") + use waker_fn::waker_fn; + let deadline = timeout.map(|t| Instant::now() + t); + let parker = parking::Parker::new(); + let unparker = parker.unparker(); + let waker = waker_fn(move || { + unparker.unpark(); + }); + let mut cx = Context::from_waker(&waker); + if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { + return true; } - #[cfg(not(target_family = "wasm"))] - { - use waker_fn::waker_fn; - let deadline = timeout.map(|t| Instant::now() + t); - let parker = parking::Parker::new(); - let unparker = parker.unparker(); - let waker = waker_fn(move || { - unparker.unpark(); - }); - let mut cx = Context::from_waker(&waker); - if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { - return true; - } - let park_deadline = |deadline: Instant| { - // Timer expirations are only delivered every ~15.6 milliseconds by default on Windows. - // We increase the resolution during this wait so that short timeouts stay reasonably short. - let _timer_guard = self.dispatcher.increase_timer_resolution(); - parker.park_deadline(deadline) - }; - - loop { - match deadline { - Some(deadline) if !park_deadline(deadline) && deadline <= Instant::now() => { - return false; - } - Some(_) => (), - None => parker.park(), - } - if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { - break true; + let park_deadline = |deadline: Instant| { + // Timer expirations are only delivered every ~15.6 milliseconds by default on Windows. + // We increase the resolution during this wait so that short timeouts stay reasonably short. + let _timer_guard = self.dispatcher.increase_timer_resolution(); + parker.park_deadline(deadline) + }; + + loop { + match deadline { + Some(deadline) if !park_deadline(deadline) && deadline <= Instant::now() => { + return false; } + Some(_) => (), + None => parker.park(), + } + if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { + break true; } } } diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 53f3ba5..bacf9fb 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -106,6 +106,7 @@ impl LocalExecutor { Task(TaskState::Spawned(task)) } + #[cfg(not(target_family = "wasm"))] pub fn block_on(&self, future: Fut) -> Fut::Output { use std::cell::Cell; @@ -123,6 +124,7 @@ impl LocalExecutor { /// Block until the future completes or timeout occurs. /// Returns Ok(output) if completed, Err(future) if timed out. + #[cfg(not(target_family = "wasm"))] pub fn block_with_timeout( &self, timeout: Duration, diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 98c5613..325ec7b 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -83,6 +83,7 @@ pub trait Scheduler: Send + Sync { /// Returns `true` if the future completed, `false` if it timed out. /// The future is passed as a pinned mutable reference so the caller /// retains ownership and can continue polling or return it on timeout. + #[cfg(not(target_family = "wasm"))] fn block( &self, session_id: Option, diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 6c27239..bd34203 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -19,7 +19,6 @@ use std::{ future::Future, mem, ops::RangeInclusive, - panic::{self, AssertUnwindSafe}, pin::Pin, sync::{ Arc, Weak, @@ -62,15 +61,15 @@ impl TestScheduler { (seed..seed + num_iterations as u64) .map(|seed| { - let mut unwind_safe_f = AssertUnwindSafe(&mut f); + let mut unwind_safe_f = std::panic::AssertUnwindSafe(&mut f); if interactive { eprintln!("Running seed: {seed}"); } - match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) { + match std::panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) { Ok(result) => result, Err(error) => { eprintln!("\x1b[31mFailing Seed: {seed}\x1b[0m"); - panic::resume_unwind(error); + std::panic::resume_unwind(error); } } }) @@ -79,12 +78,86 @@ impl TestScheduler { fn with_seed(seed: u64, f: impl AsyncFnOnce(Arc) -> R) -> R { let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::with_seed(seed))); + let output = std::cell::Cell::new(None); let future = f(scheduler.clone()); - let result = scheduler.foreground().block_on(future); + let future = async { + output.set(Some(future.await)); + }; + let mut future = std::pin::pin!(future); + scheduler.block_until(None, future.as_mut(), None); + let result = output.take().expect("test future did not complete"); scheduler.run(); // Ensure spawned tasks finish up before returning in tests result } + fn block_until( + &self, + session_id: Option, + mut future: Pin<&mut dyn Future>, + timeout: Option, + ) -> bool { + if let Some(session_id) = session_id { + self.state.lock().blocked_sessions.push(session_id); + } + + let deadline = timeout.map(|timeout| Instant::now() + timeout); + let awoken = Arc::new(AtomicBool::new(false)); + let waker = Box::new(TracingWaker { + id: None, + awoken: awoken.clone(), + thread: self.thread.clone(), + state: self.state.clone(), + }); + let waker = unsafe { Waker::new(Box::into_raw(waker) as *const (), &WAKER_VTABLE) }; + let max_ticks = if timeout.is_some() { + self.rng + .lock() + .random_range(self.state.lock().timeout_ticks.clone()) + } else { + usize::MAX + }; + let mut cx = Context::from_waker(&waker); + + let mut completed = false; + for _ in 0..max_ticks { + match future.as_mut().poll(&mut cx) { + Poll::Ready(()) => { + completed = true; + break; + } + Poll::Pending => {} + } + + let mut stepped = None; + while self.rng.lock().random() { + let stepped = stepped.get_or_insert(false); + if self.step() { + *stepped = true; + } else { + break; + } + } + + let stepped = stepped.unwrap_or(true); + let awoken = awoken.swap(false, SeqCst); + if !stepped && !awoken { + let parking_allowed = self.state.lock().allow_parking; + // In deterministic mode (parking forbidden), instantly jump to the next timer. + // In non-deterministic mode (parking allowed), let real time pass instead. + let advanced_to_timer = !parking_allowed && self.advance_clock_to_next_timer(); + if !advanced_to_timer && !self.park(deadline) { + break; + } + } + } + + if session_id.is_some() { + self.state.lock().blocked_sessions.pop(); + } + + completed + } + pub fn new(config: TestSchedulerConfig) -> Self { Self { rng: Arc::new(Mutex::new(StdRng::seed_from_u64(config.seed))), @@ -520,72 +593,14 @@ impl Scheduler for TestScheduler { /// is provided. This is to allow testing a mix of deterministic and /// non-deterministic async behavior, such as when interacting with I/O in /// an otherwise deterministic test. + #[cfg(not(target_family = "wasm"))] fn block( &self, session_id: Option, - mut future: Pin<&mut dyn Future>, + future: Pin<&mut dyn Future>, timeout: Option, ) -> bool { - if let Some(session_id) = session_id { - self.state.lock().blocked_sessions.push(session_id); - } - - let deadline = timeout.map(|timeout| Instant::now() + timeout); - let awoken = Arc::new(AtomicBool::new(false)); - let waker = Box::new(TracingWaker { - id: None, - awoken: awoken.clone(), - thread: self.thread.clone(), - state: self.state.clone(), - }); - let waker = unsafe { Waker::new(Box::into_raw(waker) as *const (), &WAKER_VTABLE) }; - let max_ticks = if timeout.is_some() { - self.rng - .lock() - .random_range(self.state.lock().timeout_ticks.clone()) - } else { - usize::MAX - }; - let mut cx = Context::from_waker(&waker); - - let mut completed = false; - for _ in 0..max_ticks { - match future.as_mut().poll(&mut cx) { - Poll::Ready(()) => { - completed = true; - break; - } - Poll::Pending => {} - } - - let mut stepped = None; - while self.rng.lock().random() { - let stepped = stepped.get_or_insert(false); - if self.step() { - *stepped = true; - } else { - break; - } - } - - let stepped = stepped.unwrap_or(true); - let awoken = awoken.swap(false, SeqCst); - if !stepped && !awoken { - let parking_allowed = self.state.lock().allow_parking; - // In deterministic mode (parking forbidden), instantly jump to the next timer. - // In non-deterministic mode (parking allowed), let real time pass instead. - let advanced_to_timer = !parking_allowed && self.advance_clock_to_next_timer(); - if !advanced_to_timer && !self.park(deadline) { - break; - } - } - } - - if session_id.is_some() { - self.state.lock().blocked_sessions.pop(); - } - - completed + self.block_until(session_id, future, timeout) } fn schedule_local(&self, session_id: SessionId, runnable: Runnable) { From 3809634d9591e7a44a0d1ed0b7b442600249c11c Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:31:41 +0000 Subject: [PATCH 22/45] gpui_macos: Migrate `PlatformWindow::prompt` to `objc2` on MacOS (#59572) # Objective Rust 1.96 emits a warning when compiling the `block` crate, that it will no longer compile past some future version of Rust. The `block` crate provides `ConcreteBlock` and `RcBlock`, which are stack- and heap-based functions often passed as callbacks to MacOS. The old `objc` crate family is deprecated and no longer maintained. The goal of the PR is to replace one call to `block::ConcreteBlock` by migrating a standalone portion of code to the objc2 crate family. A full migration to `objc2` by Zed has been long on the horizon (#22408) Additional benefits: The new code is completely typed, uses automatic memory management, and reduces lines of unsafe Rust from 50+ to 1. The lingering unsafe converts a raw pointer stored by `self` to the respective `objc2` type, since the rest of the window implementation does not use the new crates yet. ## Solution Migrate `PlatformWindow::prompt`'s MacOS implementation to `objc2`. ## Testing Direct platform code is quite fragile and hard to test, however you can exercise the codepath by running the following `gpui` example and clicking on the prompt buttons. ```sh cargo run -p gpui --example window ``` I've also built Zed with these changes and tried codepaths which activate prompts, such as deleting a file. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- crates/gpui_macos/src/platform.rs | 10 ++- crates/gpui_macos/src/window.rs | 123 +++++++++++++++--------------- 2 files changed, 70 insertions(+), 63 deletions(-) diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index c1d857c..c6f75a8 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -44,6 +44,7 @@ use objc::{ runtime::{Class, Object, Sel}, sel, sel_impl, }; +use objc2::MainThreadMarker; use parking_lot::Mutex; use ptr::null_mut; use semver::Version; @@ -163,7 +164,7 @@ unsafe fn build_classes() { } } -pub struct MacPlatform(Mutex); +pub struct MacPlatform(Mutex, MainThreadMarker); pub(crate) struct MacPlatformState { background_executor: BackgroundExecutor, @@ -195,6 +196,7 @@ pub(crate) struct MacPlatformState { impl MacPlatform { pub fn new(headless: bool) -> Self { + let marker = MainThreadMarker::new().expect("Mac platform not created on main thread"); let dispatcher = Arc::new(MacDispatcher::new()); #[cfg(feature = "font-kit")] @@ -213,7 +215,7 @@ impl MacPlatform { let keyboard_layout = MacKeyboardLayout::new(); let keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); - Self(Mutex::new(MacPlatformState { + let state = Mutex::new(MacPlatformState { headless, text_system, background_executor: BackgroundExecutor::new(dispatcher.clone()), @@ -238,7 +240,8 @@ impl MacPlatform { keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), system_notifications: crate::system_notifications::SystemNotificationState::new(), - })) + }); + Self(state, marker) } unsafe fn create_menu_bar( @@ -674,6 +677,7 @@ impl Platform for MacPlatform { foreground_executor, background_executor, renderer_context, + self.1, ))) } diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index faf27e0..88a694c 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -7,6 +7,7 @@ use crate::{ #[cfg(any(test, feature = "test-support"))] use anyhow::Result; use block::ConcreteBlock; +use block2::RcBlock; use cocoa::{ appkit::{ NSApplication, NSBackingStoreBuffered, NSColor, NSEvent, NSEventModifierFlags, NSEventType, @@ -49,10 +50,10 @@ use objc::{ runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES}, sel, sel_impl, }; -use objc2::{rc::Retained, runtime::AnyObject as Objc2Object}; +use objc2::{MainThreadMarker, rc::Retained, runtime::AnyObject as Objc2Object}; use objc2_app_kit::{ - NSBeep, NSButton as Objc2NSButton, NSView as Objc2NSView, NSWindow as Objc2NSWindow, - NSWindowButton as Objc2NSWindowButton, + NSAlert, NSAlertStyle, NSBeep, NSButton as Objc2NSButton, NSView as Objc2NSView, + NSWindow as Objc2NSWindow, NSWindowButton as Objc2NSWindowButton, }; use objc2_foundation::{NSPoint as Objc2NSPoint, NSRect as Objc2NSRect}; use parking_lot::Mutex; @@ -878,7 +879,7 @@ impl MacWindowState { unsafe impl Send for MacWindowState {} -pub(crate) struct MacWindow(Arc>); +pub(crate) struct MacWindow(Arc>, MainThreadMarker); impl MacWindow { pub fn open( @@ -902,6 +903,7 @@ impl MacWindow { foreground_executor: ForegroundExecutor, background_executor: BackgroundExecutor, renderer_context: renderer::Context, + marker: MainThreadMarker, ) -> Self { unsafe { let pool = NSAutoreleasePool::new(nil); @@ -1011,7 +1013,7 @@ impl MacWindow { let native_view = NSView::initWithFrame_(native_view, NSView::bounds(content_view)); assert!(!native_view.is_null()); - let mut window = Self(Arc::new(Mutex::new(MacWindowState { + let state = Arc::new(Mutex::new(MacWindowState { handle, foreground_executor, background_executor, @@ -1065,7 +1067,8 @@ impl MacWindow { closed: Arc::new(AtomicBool::new(false)), accesskit_adapter: None, sheet_parent: None, - }))); + })); + let mut window = Self(state, marker); (*native_window).set_ivar( WINDOW_STATE_IVAR, @@ -1511,6 +1514,8 @@ impl PlatformWindow for MacWindow { detail: Option<&str>, answers: &[PromptButton], ) -> Option> { + use objc2_foundation::{NSInteger, NSString}; + // NSAlert's first button keeps Return and Cancel keeps Escape, but the keyboard // focus (and therefore Space) defaults to Cancel, leaving the middle button of // prompts like "Save / Don't Save / Cancel" unreachable from the keyboard. Move @@ -1523,69 +1528,67 @@ impl PlatformWindow for MacWindow { .map(|(ix, _)| ix) .filter(|&ix| ix > 0); - unsafe { - let alert: id = msg_send![class!(NSAlert), alloc]; - let alert: id = msg_send![alert, init]; - let alert_style = match level { - PromptLevel::Info => 1, - PromptLevel::Warning => 0, - PromptLevel::Critical => 2, - }; - let _: () = msg_send![alert, setAlertStyle: alert_style]; - let _: () = msg_send![alert, setMessageText: ns_string(msg)]; - if let Some(detail) = detail { - let _: () = msg_send![alert, setInformativeText: ns_string(detail)]; - } + let alert = NSAlert::new(self.1); + alert.setAlertStyle(match level { + PromptLevel::Critical => NSAlertStyle::Critical, + PromptLevel::Warning => NSAlertStyle::Warning, + PromptLevel::Info => NSAlertStyle::Informational, + }); + let message = NSString::from_str(msg); + alert.setMessageText(message.as_ref()); - let mut initial_focus_button: Option = None; - for (ix, answer) in answers.iter().enumerate() { - let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer.label())]; - let _: () = msg_send![button, setTag: ix as NSInteger]; + if let Some(detail) = detail { + let detail_text = NSString::from_str(detail); + alert.setInformativeText(detail_text.as_ref()); + } - if answer.is_cancel() { - if let Some(key) = std::char::from_u32(crate::events::ESCAPE_KEY as u32) { - let _: () = - msg_send![button, setKeyEquivalent: ns_string(&key.to_string())]; - } - } else if Some(ix) == initial_focus_ix { - initial_focus_button = Some(button); + let mut initial_focus_button: Option> = None; + for (ix, answer) in answers.iter().enumerate() { + let title = NSString::from_str(answer.label()); + let button = alert.addButtonWithTitle(&title); + button.setTag(ix as NSInteger); + + if answer.is_cancel() { + if let Some(key) = core::char::from_u32(crate::events::ESCAPE_KEY as u32) { + let key = NSString::from_str(&key.to_string()); + button.setKeyEquivalent(&key); } + } else if Some(ix) == initial_focus_ix { + initial_focus_button = Some(button); } + } + + if let Some(button) = initial_focus_button { + alert.window().setInitialFirstResponder(Some(&button)); + } - if let Some(button) = initial_focus_button { - let alert_window: id = msg_send![alert, window]; - let _: () = msg_send![alert_window, setInitialFirstResponder: button]; + let (done_tx, done_rx) = oneshot::channel(); + let done_tx = Cell::new(Some(done_tx)); + + let block = RcBlock::new(move |answer: NSInteger| { + if let Some(done_tx) = done_tx.take() { + let _ = done_tx.send(answer.try_into().unwrap()); } + }); - let (done_tx, done_rx) = oneshot::channel(); - let done_tx = Cell::new(Some(done_tx)); - let block = ConcreteBlock::new(move |answer: NSInteger| { - let _: () = msg_send![alert, release]; - if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(answer.try_into().unwrap()); + let lock = self.0.lock(); + let native_window = lock.native_window; + let closed = lock.closed.clone(); + let executor = lock.foreground_executor.clone(); + executor + .spawn(async move { + if !closed.load(Ordering::Acquire) { + // SAFETY: `native_window` is an Objective-C `NSWindow` pointer + // owned by the platform window; bridge it into objc2. + let sheet_window: &Objc2NSWindow = + unsafe { &*(native_window as *const Objc2NSWindow) }; + + alert.beginSheetModalForWindow_completionHandler(sheet_window, Some(&block)); } - }); - let block = block.copy(); - let lock = self.0.lock(); - let native_window = lock.native_window; - let closed = lock.closed.clone(); - let executor = lock.foreground_executor.clone(); - executor - .spawn(async move { - if !closed.load(Ordering::Acquire) { - let _: () = msg_send![ - alert, - beginSheetModalForWindow: native_window - completionHandler: block - ]; - } else { - let _: () = msg_send![alert, release]; - } - }) - .detach(); + }) + .detach(); - Some(done_rx) - } + Some(done_rx) } fn activate(&self) { From 6c4735c50cfed43f1841a0538a715446a063e2da Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Mon, 31 Aug 2026 17:08:14 +0000 Subject: [PATCH 23/45] gpui: Platform-specific scroll physics (#63505) Adds platform-specific scroll behaviour, and also exposes predicted events for better latency --- crates/gpui/src/gestures.rs | 645 ++++++++++++++++++++++++++++++--- crates/gpui/src/interactive.rs | 9 + crates/gpui/src/window.rs | 22 +- 3 files changed, 618 insertions(+), 58 deletions(-) diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index 4fd4715..edf7895 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -108,10 +108,8 @@ pub struct GestureTuning { /// How long a touch must remain within [`Self::touch_slop`] to be /// recognized as a long press. pub long_press_duration: Duration, - /// Per-millisecond decay factor applied to scroll momentum after a fling. - /// (`UIScrollView` uses `0.998` per millisecond for its normal - /// deceleration rate.) - pub momentum_decay_per_ms: f32, + /// How scroll momentum decelerates after a fling. + pub scroll_physics: ScrollPhysics, /// Minimum release velocity, in pixels per second, required to start /// scroll momentum. pub min_fling_velocity: f32, @@ -124,12 +122,214 @@ impl Default for GestureTuning { multi_tap_interval: Duration::from_millis(400), multi_tap_slop: px(16.), long_press_duration: Duration::from_millis(500), - momentum_decay_per_ms: 0.998, + scroll_physics: ScrollPhysics::ios(), min_fling_velocity: 50., } } } +/// How free scrolling decelerates after a fling. +/// +/// This models deceleration only. Boundary behavior β€” bouncing, edge glow, +/// clamping β€” is the scroll container's policy: the container is the one that +/// knows its extents. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScrollPhysics { + /// Exponential velocity decay, the `UIScrollView` model: + /// `velocity(t) = vβ‚€ Β· decay_per_msᡐ˒`. + Exponential { + /// Per-millisecond velocity decay factor. `UIScrollView`'s normal + /// deceleration rate is `0.998`. + decay_per_ms: f32, + }, + /// The friction spline of Android's `OverScroller`: fling duration and + /// distance follow a logarithmic deceleration law, and progress along + /// the fling follows a cubic-Bezier ease-out curve. Transcribed from + /// AOSP's `SplineOverScroller` (Apache-2.0). + FrictionSpline { + /// The scroll friction coefficient; + /// `ViewConfiguration.getScrollFriction()` is `0.015` on Android. + friction: f32, + /// Pixels per physical inch of the display, in the coordinate space + /// the fling runs in. Android folds display density into its + /// deceleration coefficient, so the same finger speed flings + /// further in pixels on a denser screen. + pixels_per_inch: f32, + }, +} + +impl ScrollPhysics { + /// iOS scroll feel: `UIScrollView`'s normal deceleration rate. + pub fn ios() -> Self { + Self::Exponential { + decay_per_ms: 0.998, + } + } + + /// Android scroll feel: `OverScroller` with stock friction, at Android's + /// nominal density of 160 density-independent pixels per inch β€” the + /// right pairing when fling distances are in logical pixels. Platforms + /// that fling in physical pixels, or know the display's true density in + /// their logical space, should construct + /// [`ScrollPhysics::FrictionSpline`] directly. + pub fn android() -> Self { + Self::FrictionSpline { + friction: 0.015, + pixels_per_inch: 160., + } + } + + /// How long a fling released at `speed` pixels per second coasts before + /// it stops. + fn fling_duration(self, speed: f32) -> Duration { + match self { + Self::Exponential { decay_per_ms } => { + if speed <= MOMENTUM_STOP_VELOCITY { + return Duration::ZERO; + } + let milliseconds = (MOMENTUM_STOP_VELOCITY / speed).ln() / decay_per_ms.ln(); + Duration::from_secs_f32(milliseconds / 1000.) + } + Self::FrictionSpline { + friction, + pixels_per_inch, + } => { + if speed <= 0. { + return Duration::ZERO; + } + let deceleration = friction_spline::deceleration(speed, friction, pixels_per_inch); + let seconds = (deceleration / (friction_spline::deceleration_rate() - 1.)).exp(); + Duration::from_secs_f64(seconds) + } + } + } + + /// Distance traveled `elapsed` into a fling released at `speed` pixels + /// per second, in pixels along the fling direction. Evaluated in closed + /// form so the trajectory is independent of tick timing. + fn fling_distance(self, speed: f32, elapsed: Duration) -> f32 { + let duration = self.fling_duration(speed); + if duration.is_zero() { + return 0.; + } + let elapsed = elapsed.min(duration); + match self { + Self::Exponential { decay_per_ms } => { + // βˆ«β‚€α΅— vβ‚€Β·kᡐ˒ dms, with speed converted to pixels per + // millisecond. + let milliseconds = elapsed.as_secs_f32() * 1000.; + (speed / 1000.) * (decay_per_ms.powf(milliseconds) - 1.) / decay_per_ms.ln() + } + Self::FrictionSpline { + friction, + pixels_per_inch, + } => { + let deceleration = friction_spline::deceleration(speed, friction, pixels_per_inch); + let rate = friction_spline::deceleration_rate(); + let total_distance = friction as f64 + * friction_spline::physical_coefficient(pixels_per_inch) + * (rate / (rate - 1.) * deceleration).exp(); + let progress = elapsed.as_secs_f64() / duration.as_secs_f64(); + total_distance as f32 * friction_spline::distance_coefficient(progress as f32) + } + } + } +} + +/// The fling model of Android's `OverScroller.SplineOverScroller`, +/// transcribed from AOSP (Apache-2.0). `SPLINE_TIME`, which AOSP uses for +/// programmatic scroll animations rather than flings, is intentionally not +/// transcribed. +mod friction_spline { + use std::sync::LazyLock; + + const NB_SAMPLES: usize = 100; + const INFLEXION: f32 = 0.35; + const START_TENSION: f32 = 0.5; + const END_TENSION: f32 = 1.0; + const P1: f32 = START_TENSION * INFLEXION; + const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION); + + /// Android's `DECELERATION_RATE`: `ln(0.78) / ln(0.9)`. + pub(super) fn deceleration_rate() -> f64 { + 0.78f64.ln() / 0.9f64.ln() + } + + /// `SPLINE_POSITION` from AOSP's static initializer: fractional fling + /// distance sampled at 100 evenly spaced fractions of the fling + /// duration, from a cubic Bezier with control points shaped by + /// `INFLEXION` and the start/end tensions. + static SPLINE_POSITION: LazyLock<[f32; NB_SAMPLES + 1]> = LazyLock::new(|| { + let mut spline_position = [0f32; NB_SAMPLES + 1]; + let mut x_min = 0f32; + for (i, sample) in spline_position.iter_mut().take(NB_SAMPLES).enumerate() { + let alpha = i as f32 / NB_SAMPLES as f32; + let mut x_max = 1f32; + let (x, coefficient) = loop { + let x = x_min + (x_max - x_min) / 2.; + let coefficient = 3. * x * (1. - x); + let time = coefficient * ((1. - x) * P1 + x * P2) + x * x * x; + if (time - alpha).abs() < 1e-5 { + break (x, coefficient); + } + if time > alpha { + x_max = x; + } else { + x_min = x; + } + }; + *sample = coefficient * ((1. - x) * START_TENSION + x) + x * x * x; + } + spline_position[NB_SAMPLES] = 1.; + spline_position + }); + + /// `SensorManager.GRAVITY_EARTH Β· 39.37 in/m Β· ppi Β· 0.84`, AOSP's + /// `mPhysicalCoeff`: gravity expressed in pixels, times an empirical + /// "look and feel" tuning factor. + pub(super) fn physical_coefficient(pixels_per_inch: f32) -> f64 { + 9.80665 * 39.37 * pixels_per_inch as f64 * 0.84 + } + + /// AOSP's `getSplineDeceleration`. + pub(super) fn deceleration(speed: f32, friction: f32, pixels_per_inch: f32) -> f64 { + (INFLEXION as f64 * speed as f64 + / (friction as f64 * physical_coefficient(pixels_per_inch))) + .ln() + } + + /// Fraction of the total fling distance covered at fraction `time` of + /// the fling duration: table lookup plus linear interpolation, as in + /// `SplineOverScroller.update`. + pub(super) fn distance_coefficient(time: f32) -> f32 { + if time >= 1. { + return 1.; + } + let index = ((NB_SAMPLES as f32 * time) as usize).min(NB_SAMPLES - 1); + let time_lower = index as f32 / NB_SAMPLES as f32; + let time_upper = (index + 1) as f32 / NB_SAMPLES as f32; + let distance_lower = SPLINE_POSITION[index]; + let distance_upper = SPLINE_POSITION[index + 1]; + let velocity_coefficient = (distance_upper - distance_lower) / (time_upper - time_lower); + distance_lower + (time - time_lower) * velocity_coefficient + } + + #[cfg(test)] + pub(super) fn bezier_time_and_position(parameter: f32) -> (f32, f32) { + let coefficient = 3. * parameter * (1. - parameter); + let cubed = parameter * parameter * parameter; + ( + coefficient * ((1. - parameter) * P1 + parameter * P2) + cubed, + coefficient * ((1. - parameter) * START_TENSION + parameter) + cubed, + ) + } + + #[cfg(test)] + pub(super) fn spline_position_samples() -> &'static [f32; NB_SAMPLES + 1] { + &SPLINE_POSITION + } +} + /// The set of gesture kinds that participate in recognition. /// /// Used by [`PlatformGestures::native_recognizers`] to declare which gestures @@ -205,17 +405,12 @@ impl PlatformGestures for NullPlatformGestures {} /// Flutter's `kMaxFlingVelocity`). const MAX_FLING_VELOCITY: f32 = 8000.; -/// Momentum below this speed, in pixels per second, is imperceptible: the -/// fling stops and the synthetic scroll stream is closed. +/// Momentum below this speed, in pixels per second, is imperceptible. The +/// exponential model, which never mathematically stops, treats reaching this +/// speed as the end of the fling. (The friction spline has a finite duration +/// of its own.) const MOMENTUM_STOP_VELOCITY: f32 = 10.; -/// Upper bound on the time a single momentum tick may integrate, so a stalled -/// frame loop (backgrounded window, long pause) resumes without a huge jump. -/// Must stay well above a plausible worst-case frame interval: clamping a -/// normal slow frame would advance the fling slower than real time, making -/// momentum crawl on exactly the devices that already render slowly. -const MOMENTUM_MAX_TICK: Duration = Duration::from_millis(250); - /// How far back the release-velocity estimate looks. Samples older than this /// reflect an earlier part of the gesture, not the speed at release. const VELOCITY_WINDOW: Duration = Duration::from_millis(100); @@ -274,7 +469,11 @@ enum TouchGestureState { struct ActiveTouch { id: TouchId, start_position: Point, - last_position: Point, + /// The position pan output has scrolled to so far. While panning this + /// may run ahead of the raw touch by the event's predicted position; + /// the release event targets the raw position again, so the total + /// scrolled distance always converges to the finger's actual travel. + emitted_position: Point, velocity_tracker: VelocityTracker, } @@ -284,13 +483,22 @@ struct CompletedTap { count: usize, } +/// One fling in progress. The trajectory is a closed-form curve of elapsed +/// time β€” each tick evaluates it and emits the increment β€” so the fling is +/// exactly frame-rate independent: a stalled frame simply resumes further +/// along the same curve. struct Momentum { /// Where the pan started; synthesized scroll events keep hit-testing /// there so momentum stays with the container the gesture began on. position: Point, - /// Pixels per second. - velocity: Point, - last_tick: Instant, + /// Unit vector of the release velocity. + direction: Point, + /// Release speed in pixels per second. + speed: f32, + started_at: Instant, + duration: Duration, + /// Distance already emitted along `direction`, in pixels. + emitted_distance: f32, } impl TouchGestureRecognizer { @@ -318,22 +526,40 @@ impl TouchGestureRecognizer { let mut recognized = SmallVec::new(); match event.phase { TouchPhase::Started => { - if let Some(momentum) = self.momentum.take() { + let caught_fling = if let Some(momentum) = self.momentum.take() { recognized.push(RecognizedTouchGesture::Scroll(scroll_event( momentum.position, Point::default(), TouchPhase::Ended, ))); - } + true + } else { + false + }; if matches!(self.state, TouchGestureState::Idle) { let mut velocity_tracker = VelocityTracker::default(); velocity_tracker.push(now, event.position); - self.state = TouchGestureState::Pending(ActiveTouch { + let touch = ActiveTouch { id: event.id, start_position: event.position, - last_position: event.position, + emitted_position: event.position, velocity_tracker, - }); + }; + if caught_fling { + // A touch that catches a fling is a drag from the + // first pixel: waiting out the slop would freeze the + // content mid-scroll and then jump. It can also never + // be a tap; releasing it just leaves the content + // stopped, as on Android and iOS. + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + Point::default(), + TouchPhase::Started, + ))); + self.state = TouchGestureState::Panning(touch); + } else { + self.state = TouchGestureState::Pending(touch); + } } } TouchPhase::Moved => match mem::replace(&mut self.state, TouchGestureState::Idle) { @@ -344,10 +570,11 @@ impl TouchGestureRecognizer { // Carry the full movement so far into the first scroll // step: the content catches up to the finger instead // of losing the slop distance. - touch.last_position = event.position; + let target = event.predicted_position.unwrap_or(event.position); + touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, - accumulated, + target - touch.start_position, TouchPhase::Started, ))); self.state = TouchGestureState::Panning(touch); @@ -357,8 +584,9 @@ impl TouchGestureRecognizer { } TouchGestureState::Panning(mut touch) if touch.id == event.id => { touch.velocity_tracker.push(now, event.position); - let delta = event.position - touch.last_position; - touch.last_position = event.position; + let target = event.predicted_position.unwrap_or(event.position); + let delta = target - touch.emitted_position; + touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, delta, @@ -402,12 +630,6 @@ impl TouchGestureRecognizer { }); } TouchGestureState::Panning(touch) if touch.id == event.id => { - let delta = event.position - touch.last_position; - recognized.push(RecognizedTouchGesture::Scroll(scroll_event( - touch.start_position, - delta, - TouchPhase::Ended, - ))); // The release deliberately contributes no velocity // sample: it usually repeats the last movement's position // with a later timestamp, which would dilute the @@ -427,18 +649,45 @@ impl TouchGestureRecognizer { touch.velocity_tracker.velocity() }; let speed = (velocity.x.powi(2) + velocity.y.powi(2)).sqrt(); + let mut release_delta = event.position - touch.emitted_position; if speed >= self.tuning.min_fling_velocity { - let velocity = if speed > MAX_FLING_VELOCITY { - velocity * (MAX_FLING_VELOCITY / speed) - } else { - velocity - }; - self.momentum = Some(Momentum { - position: touch.start_position, - velocity, - last_tick: now, - }); + let direction = point(velocity.x / speed, velocity.y / speed); + let speed = speed.min(MAX_FLING_VELOCITY); + let duration = self.tuning.scroll_physics.fling_duration(speed); + if !duration.is_zero() { + let total_distance = + self.tuning.scroll_physics.fling_distance(speed, duration); + // Prediction may have left the content ahead of + // the raw release position. Emitting that + // correction here would visibly snap the content + // backwards just as the fling launches, so fold + // it into the fling instead: start the curve + // already advanced by the overshoot, keeping the + // total travel exact while staying monotonic. + let overshoot = -(f32::from(release_delta.x) * direction.x + + f32::from(release_delta.y) * direction.y); + let emitted_distance = if overshoot > 0. && overshoot < total_distance { + release_delta += + point(px(direction.x * overshoot), px(direction.y * overshoot)); + overshoot + } else { + 0. + }; + self.momentum = Some(Momentum { + position: touch.start_position, + direction, + speed, + started_at: now, + duration, + emitted_distance, + }); + } } + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + release_delta, + TouchPhase::Ended, + ))); } other => self.state = other, }, @@ -470,21 +719,19 @@ impl TouchGestureRecognizer { fn tick_momentum_at(&mut self, now: Instant) -> Option { let momentum = self.momentum.as_mut()?; - let elapsed = now - .duration_since(momentum.last_tick) - .min(MOMENTUM_MAX_TICK); - momentum.last_tick = now; + let elapsed = now.duration_since(momentum.started_at); + let distance = self + .tuning + .scroll_physics + .fling_distance(momentum.speed, elapsed); + let step = distance - momentum.emitted_distance; + momentum.emitted_distance = distance; let delta = point( - px(momentum.velocity.x * elapsed.as_secs_f32()), - px(momentum.velocity.y * elapsed.as_secs_f32()), + px(momentum.direction.x * step), + px(momentum.direction.y * step), ); - momentum.velocity *= self - .tuning - .momentum_decay_per_ms - .powf(elapsed.as_secs_f32() * 1000.); - let speed = (momentum.velocity.x.powi(2) + momentum.velocity.y.powi(2)).sqrt(); let position = momentum.position; - if speed < MOMENTUM_STOP_VELOCITY { + if elapsed >= momentum.duration { self.momentum = None; Some(RecognizedTouchGesture::Scroll(scroll_event( position, @@ -839,6 +1086,116 @@ mod tests { assert_eq!(scroll.touch_phase, TouchPhase::Ended); } + #[test] + fn predicted_positions_lead_the_pan_but_totals_converge_on_release() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + // The first pan step scrolls to the predicted position, not the raw one. + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.); + moved.predicted_position = Some(point(px(100.), px(128.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(28.))); + + // The next step is measured from where the previous prediction left + // the content, so an overshoot is paid back here. + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 130.); + moved.predicted_position = Some(point(px(100.), px(134.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.))); + + // A release without a fling (the finger stopped long before lifting) + // targets the raw position: the total scrolled distance equals the + // finger's actual travel despite the predictions. + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 130.), + now + Duration::from_millis(120), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + assert!(!recognizer.has_momentum()); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-4.))); + } + + #[test] + fn predicted_overshoot_folds_into_the_fling_without_scrolling_backwards() { + let now = Instant::now(); + let mut total_with_prediction = 0f32; + let mut total_without_prediction = 0f32; + for use_prediction in [true, false] { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let mut total = 0f32; + let mut drain = |recognized: &[RecognizedTouchGesture], upward_only: bool| { + for gesture in recognized { + let RecognizedTouchGesture::Scroll(scroll) = gesture else { + panic!("expected scroll, got {gesture:?}"); + }; + let delta = scroll.delta.pixel_delta(px(16.)).y; + if upward_only { + assert!( + delta <= px(0.), + "content moved backwards by {delta:?} during an upward gesture" + ); + } + total += f32::from(delta); + } + }; + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 500.), + now, + ); + for step in 1..=5u64 { + let raw_y = 500. - step as f32 * 40.; + let mut moved = touch_event(TouchId(1), TouchPhase::Moved, 100., raw_y); + if use_prediction { + moved.predicted_position = Some(point(px(100.), px(raw_y - 25.))); + } + let recognized = + recognizer.handle_event_at(&moved, now + Duration::from_millis(step * 16)); + drain(&recognized, use_prediction); + } + // The release leaves the emitted position 25px ahead of the raw + // one; with prediction the correction must not scroll backwards. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 300.), + now + Duration::from_millis(90), + ); + drain(&recognized, use_prediction); + assert!(recognizer.has_momentum()); + let mut tick = now + Duration::from_millis(90); + while recognizer.has_momentum() { + tick += Duration::from_millis(16); + if let Some(gesture) = recognizer.tick_momentum_at(tick) { + drain(&[gesture], use_prediction); + } + } + + if use_prediction { + total_with_prediction = total; + } else { + total_without_prediction = total; + } + } + // Folding the overshoot into the fling redistributes the travel but + // must not change where the content comes to rest. + assert!( + (total_with_prediction - total_without_prediction).abs() < 0.01, + "totals diverged: {total_with_prediction} vs {total_without_prediction}" + ); + } + #[test] fn fast_release_starts_momentum_that_decays_to_a_stop() { let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); @@ -945,11 +1302,70 @@ mod tests { now + Duration::from_millis(200), ); assert!(!recognizer.has_momentum()); + let [ + RecognizedTouchGesture::Scroll(closing), + RecognizedTouchGesture::Scroll(opening), + ] = recognized.as_slice() + else { + panic!("expected closing and opening scrolls, got {recognized:?}"); + }; + assert_eq!(closing.touch_phase, TouchPhase::Ended); + assert!(closing.delta.pixel_delta(px(16.)).is_zero()); + assert_eq!(opening.touch_phase, TouchPhase::Started); + assert!(opening.delta.pixel_delta(px(16.)).is_zero()); + } + + #[test] + fn catching_a_fling_pans_immediately_and_never_taps() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 300.), + now, + ); + for step in 1..=3 { + recognizer.handle_event_at( + &touch_event( + TouchId(1), + TouchPhase::Moved, + 100., + 300. - step as f32 * 33., + ), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 200.), + now + Duration::from_millis(64), + ); + assert!(recognizer.has_momentum()); + + recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 100., 200.), + now + Duration::from_millis(200), + ); + + // A movement well within the slop scrolls immediately. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Moved, 100., 197.), + now + Duration::from_millis(216), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-3.))); + + // Releasing the catch is not a tap. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 100., 197.), + now + Duration::from_millis(232), + ); let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { - panic!("expected closing scroll, got {recognized:?}"); + panic!("expected scroll, got {recognized:?}"); }; assert_eq!(scroll.touch_phase, TouchPhase::Ended); - assert!(scroll.delta.pixel_delta(px(16.)).is_zero()); } #[test] @@ -1018,6 +1434,120 @@ mod tests { assert_eq!(scroll.touch_phase, TouchPhase::Started); } + #[test] + fn spline_position_table_matches_the_bezier_curve() { + let samples = friction_spline::spline_position_samples(); + // AOSP's initializer solves sample 0 numerically like every other + // sample, so it lands within solver tolerance of zero, not at zero. + assert!(samples[0].abs() < 1e-4); + assert_eq!(samples[100], 1.); + for window in samples.windows(2) { + assert!(window[0] < window[1], "table must be strictly increasing"); + } + // Each table entry must lie on the defining parametric Bezier: for + // sample i there must be a curve parameter whose time component is + // i/100 and whose position component is the stored value. + for (i, &stored_position) in samples.iter().enumerate().take(100) { + let alpha = i as f32 / 100.; + let (mut lower, mut upper) = (0f32, 1f32); + for _ in 0..50 { + let middle = (lower + upper) / 2.; + let (time, _) = friction_spline::bezier_time_and_position(middle); + if time > alpha { + upper = middle; + } else { + lower = middle; + } + } + let (time, position) = friction_spline::bezier_time_and_position((lower + upper) / 2.); + assert!( + (time - alpha).abs() < 1e-4, + "sample {i}: time {time} != {alpha}" + ); + assert!( + (position - stored_position).abs() < 1e-3, + "sample {i}: position {position} != stored {stored_position}" + ); + } + } + + #[test] + fn fling_curves_are_sane_for_both_physics() { + for physics in [ScrollPhysics::ios(), ScrollPhysics::android()] { + let slow = physics.fling_duration(500.); + let fast = physics.fling_duration(4000.); + assert!(slow > Duration::ZERO, "{physics:?}"); + assert!(fast > slow, "faster flings must coast longer: {physics:?}"); + + let halfway = physics.fling_distance(4000., fast / 2); + let total = physics.fling_distance(4000., fast); + assert!(halfway > 0. && halfway < total, "{physics:?}"); + assert!( + physics.fling_distance(4000., fast * 2) == total, + "distance must not grow past the fling duration: {physics:?}" + ); + assert!( + physics.fling_distance(4000., fast) > physics.fling_distance(500., slow), + "faster flings must travel further: {physics:?}" + ); + } + } + + #[test] + fn momentum_is_frame_rate_independent() { + // The same fling ticked at 60Hz and as one huge stalled frame must + // cover identical ground. + let total_distance_with_tick_length = |tick: Duration| -> f32 { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning { + scroll_physics: ScrollPhysics::android(), + ..GestureTuning::default() + }); + let now = Instant::now(); + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 500.), + now, + ); + for step in 1..=3 { + recognizer.handle_event_at( + &touch_event( + TouchId(1), + TouchPhase::Moved, + 100., + 500. - step as f32 * 40., + ), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 380.), + now + Duration::from_millis(64), + ); + assert!(recognizer.has_momentum()); + + let mut total = 0f32; + let mut time = now + Duration::from_millis(64); + let mut guard = 0; + while recognizer.has_momentum() { + time += tick; + guard += 1; + assert!(guard < 10_000, "momentum never stopped"); + if let Some(RecognizedTouchGesture::Scroll(scroll)) = + recognizer.tick_momentum_at(time) + { + total += f32::from(scroll.delta.pixel_delta(px(16.)).y); + } + } + total + }; + + let smooth = total_distance_with_tick_length(Duration::from_millis(16)); + let stalled = total_distance_with_tick_length(Duration::from_secs(10)); + assert!( + (smooth - stalled).abs() < 0.01, + "expected identical fling distance, got {smooth} vs {stalled}" + ); + } + #[test] fn flick_velocity_reflects_release_speed_not_window_average() { // A uniformly accelerating flick: position grows quadratically, so @@ -1066,6 +1596,7 @@ mod tests { id, phase, position: point(px(x), px(y)), + predicted_position: None, force: None, } } diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 790593c..be77a1a 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -123,6 +123,15 @@ pub struct TouchEvent { pub phase: TouchPhase, /// The position of the touch in window coordinates. pub position: Point, + /// Where the platform predicts the touch will be roughly one frame from + /// now, in the same coordinate space as `position`, when the platform + /// offers a prediction for a [`TouchPhase::Moved`] event. + /// + /// Best-effort latency compensation only: it may influence how far a + /// recognized pan scrolls within a frame, but never hit testing, gesture + /// classification, or velocity estimation, and any error it introduces + /// must be corrected by later events for the same touch. + pub predicted_position: Option>, /// Normalized touch force in `0.0..=1.0`, if the hardware reports it. pub force: Option, } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 085a020..01a2255 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1203,6 +1203,7 @@ pub struct Window { window_profiler: profiler::WindowProfiler, last_input_modality: InputModality, touch_gestures: TouchGestureRecognizer, + touch_prediction_enabled: bool, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -1899,6 +1900,7 @@ impl Window { .gestures() .map_or_else(GestureTuning::default, |gestures| gestures.tuning()), ), + touch_prediction_enabled: true, refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -5424,11 +5426,29 @@ impl Window { } } + /// Whether recognized touch pans may use the platform's predicted touch + /// positions ([`TouchEvent::predicted_position`]) to compensate for input + /// latency. Defaults to true. + pub fn touch_prediction_enabled(&self) -> bool { + self.touch_prediction_enabled + } + + /// Sets whether recognized touch pans may use the platform's predicted + /// touch positions. Disabling drops [`TouchEvent::predicted_position`] + /// before gesture recognition, so pans track only raw touch positions. + pub fn set_touch_prediction_enabled(&mut self, enabled: bool) { + self.touch_prediction_enabled = enabled; + } + /// Runs the portable gesture recognizer over a raw touch event and /// dispatches whatever it resolves (scroll steps, synthesized taps) /// through the ordinary mouse-event path. fn dispatch_touch_event(&mut self, event: &TouchEvent, cx: &mut App) { - let recognized_gestures = self.touch_gestures.handle_event(event); + let mut event = event.clone(); + if !self.touch_prediction_enabled { + event.predicted_position = None; + } + let recognized_gestures = self.touch_gestures.handle_event(&event); let mut tapped = false; for gesture in recognized_gestures { tapped |= matches!(gesture, RecognizedTouchGesture::Tap { .. }); From 5e436c148c1f7ef66cdab00a0e171f1936f5e7e3 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 31 Aug 2026 17:37:00 +0000 Subject: [PATCH 24/45] Support building with corgi (#63396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [corgi](https://github.com/ConradIrwin/corgi) is a cargo-compatible build tool that runs `build.rs` scripts and proc-macros in a sandbox (no network, no ambient env, reads limited to a package's own `.rs` sources unless declared). This makes Zed buildable under it. Cargo builds (dev and release) are unaffected. ### Changes - **`scratch` patch**: patch `scratch` to a small local copy (`corgi-patches/scratch`) that reads `OUT_DIR` at runtime instead of baking it in at compile time. `cxx-build` writes its shared cxxbridge headers into `scratch::path(...)`; upstream returns a single global dir (outside any action's `OUT_DIR`) that the sandbox can't grant, while this patch gives each build script a private, writable dir in its own entry. webrtc-sys is self-contained, so per-crate scratch dirs are sufficient. Kept local rather than a cxx git fork because a git checkout of cxx needs symlink support that Windows cargo CI lacks. - **Dev asset loading**: add `util::dev_fs_embed!` and switch dev-mode `rust_embed` asset sources (`assets`, `settings`, `grammars`, `agent`, `edit_prediction_cli`) to read from the checkout at runtime, locating the repo root by walking from the executable up to the enclosing `.git`. This avoids baking `CARGO_MANIFEST_DIR` into the binary (corgi rejects artifacts that embed the build-time checkout path, and it's wrong in any other worktree). Release builds still embed via `#[derive(RustEmbed)]`. - **Stop baking checkout paths into artifacts**: drop `gpui::GPUI_MANIFEST_DIR`; resolve `../gpui` from `gpui_apple`'s build script; stage the metal shader into `OUT_DIR` before compiling so the `.metallib` records the output dir, not the checkout; resolve the repo root at runtime in `remote` and `inspector_ui` (removing `inspector_ui`'s `build.rs` / `ZED_REPO_DIR`). - **corgi.toml**: declare the pinned tools (`cmake`, prebuilt libwebrtc via `LK_CUSTOM_WEBRTC`), a release-only `ZED_COMMIT_SHA` probe, and the non-`.rs` / cross-package reads each package performs. ### Validation - `corgi check` + `corgi build`, dev and release, for `zed` (plus `cli`, `remote_server`, `ep`) with a warm C++ cache β€” clean; built binaries embed no checkout path. - `corgi fmt --check`, `corgi clippy`, and `corgi test` on the changed crates (922 tests pass). - `cargo check -p util` locally; relying on CI for the full cargo build/test matrix. ### Notes for the reviewer - Per the repo rule, the first two lines of `README.md` are the review-confirmation marker; remove them before merging (this is what Danger is failing on). - `remote_server` cross-compilation (linux-musl via zigbuild) is not yet covered by `corgi.toml` β€” host (macOS arm64) only for now. ### Suggested .rules additions Only if the team wants corgi guidance in `.rules` (it recurred several times here): dev builds must not bake the checkout path into artifacts β€” no `env!("CARGO_MANIFEST_DIR")` or `file!()`-derived absolute paths in library/binary output. Resolve the repo root at runtime via `util::dev_repo_root()` (walks to `.git`), and use `util::dev_fs_embed!` for dev-mode `rust_embed` sources. Release Notes: - N/A --------- Co-authored-by: Ben Kunkle --- crates/gpui/src/gpui.rs | 2 - crates/gpui_apple/Cargo.toml | 1 - crates/gpui_apple/build.rs | 27 +++-- crates/util/src/util.rs | 190 +++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 10 deletions(-) diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index b770281..51585b5 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -5,8 +5,6 @@ #![allow(unused_mut)] // False positives in platform specific code extern crate self as gpui; -#[doc(hidden)] -pub static GPUI_MANIFEST_DIR: &'static str = env!("CARGO_MANIFEST_DIR"); #[macro_use] mod action; mod app; diff --git a/crates/gpui_apple/Cargo.toml b/crates/gpui_apple/Cargo.toml index 1d8db9b..fc04bcc 100644 --- a/crates/gpui_apple/Cargo.toml +++ b/crates/gpui_apple/Cargo.toml @@ -38,7 +38,6 @@ parking_lot.workspace = true [target.'cfg(target_os = "macos")'.build-dependencies] cbindgen.workspace = true -gpui.workspace = true [target.'cfg(target_os = "macos")'.dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/gpui_apple/build.rs b/crates/gpui_apple/build.rs index 956cf94..ef89a66 100644 --- a/crates/gpui_apple/build.rs +++ b/crates/gpui_apple/build.rs @@ -100,9 +100,11 @@ mod macos_build { output_path } - /// Locate the gpui crate directory relative to this crate. + /// Locate the gpui crate directory relative to this crate. Resolved at + /// build-script runtime against this crate's manifest dir, so no checkout + /// path is baked into a compiled artifact (which corgi rejects). fn find_gpui_crate_dir() -> PathBuf { - gpui::GPUI_MANIFEST_DIR.into() + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("../gpui") } /// To enable runtime compilation, we need to "stitch" the shaders file with the generated header @@ -133,6 +135,13 @@ mod macos_build { PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.metallib"); println!("cargo:rerun-if-changed={}", shader_path); + // The metal compiler records the resolved absolute path of its input + // unconditionally. Compile a copy staged in OUT_DIR so the recorded + // location is the build's canonical output directory, never the + // checkout (corgi rejects artifacts that embed the build path). + let staged_shader_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.metal"); + std::fs::copy(shader_path, &staged_shader_path).unwrap(); + let output = Command::new("xcrun") .args([ "-sdk", @@ -142,11 +151,9 @@ mod macos_build { "-mmacosx-version-min=10.15.7", "-MO", "-c", - shader_path, - "-include", - (header_path.to_str().unwrap()), - "-o", ]) + .arg(&staged_shader_path) + .args(["-include", header_path.to_str().unwrap(), "-o"]) .arg(&air_output_path) .output() .unwrap(); @@ -161,7 +168,7 @@ mod macos_build { let output = Command::new("xcrun") .args(["-sdk", "macosx", "metallib"]) - .arg(air_output_path) + .arg(&air_output_path) .arg("-o") .arg(metallib_output_path) .output() @@ -174,5 +181,11 @@ mod macos_build { ); process::exit(1); } + + // The .air intermediate records the compiler's working directory in + // its debug info; the metallib built from it does not. Nothing reads + // the .air after this point, so drop it rather than leave a + // checkout-path-bearing file in OUT_DIR. + std::fs::remove_file(&air_output_path).unwrap(); } } diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 6ab84f0..36aeb20 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -634,6 +634,196 @@ pub fn asset_str(path: &str) -> Cow<'static, str> { } } +/// The checkout that produced this binary, resolved at runtime by walking up to +/// the first ancestor that contains a `.git` entry (a directory in a normal +/// clone, a file in a git worktree or submodule). Cargo and corgi both place +/// built binaries under `/target//`, so the repository root is +/// always an ancestor of the executable. +/// +/// Dev-only affordances use this instead of baking a build-time path into the +/// artifact: such a path points at the wrong checkout from any other worktree +/// and, under corgi, is rejected because artifacts must be checkout-independent +/// to be shared across worktrees. +/// +/// The executable's launch path is tried first, then its canonical form, then +/// the working directory. In CI, `target/` (or the checkout root) can be a +/// symlink onto another volume, so canonicalizing the executable alone can walk +/// off the checkout and miss `.git`; the launch path and the test runner's cwd +/// (a crate dir under the checkout) stay inside it. +pub fn dev_repo_root() -> Option<&'static std::path::Path> { + use std::path::PathBuf; + static ROOT: std::sync::OnceLock> = std::sync::OnceLock::new(); + ROOT.get_or_init(|| { + let exe = std::env::current_exe().ok(); + let candidates = [ + exe.clone(), + exe.and_then(|exe| exe.canonicalize().ok()), + std::env::current_dir().ok(), + ]; + candidates.into_iter().flatten().find_map(|start| { + Some( + start + .ancestors() + .find(|dir| dir.join(".git").exists())? + .to_path_buf(), + ) + }) + }) + .as_deref() +} + +/// Re-exports that back [`fs_embed!`] so a caller only needs to depend on `util`, +/// not on `rust_embed` directly (the macro's dependency is an implementation +/// detail). Hidden from the public API. +#[doc(hidden)] +pub mod __rust_embed { + pub use rust_embed::{EmbeddedFile, Filenames, Metadata, RustEmbed, utils}; +} + +/// Backs the dev arm of [`fs_embed!`]'s `iter`: every file under the root-relative +/// directory that passes the same rust_embed include/exclude globs the +/// release derive uses, so the dev and release file sets are identical. Reuses +/// rust_embed's own matcher rather than reimplementing glob semantics. +#[cfg(debug_assertions)] +#[doc(hidden)] +pub fn __fs_embed_iter( + root_relative: &str, + includes: &[&str], + excludes: &[&str], +) -> rust_embed::Filenames { + let Some(root) = dev_repo_root().map(|root| root.join(root_relative)) else { + return rust_embed::Filenames::Dynamic(Box::new(std::iter::empty::< + std::borrow::Cow<'static, str>, + >())); + }; + let matcher = rust_embed::utils::PathMatcher::new(includes, excludes); + let names: Vec> = + rust_embed::utils::get_files(root.to_string_lossy().into_owned(), matcher) + .map(|entry| std::borrow::Cow::Owned(entry.rel_path)) + .collect(); + rust_embed::Filenames::Dynamic(Box::new(names.into_iter())) +} + +/// Backs the dev arm of [`fs_embed!`]'s `get`: reads a single file from the +/// checkout, returning `None` when the include/exclude globs filter it out so +/// `get` matches release's embedded set exactly. +#[cfg(debug_assertions)] +#[doc(hidden)] +pub fn __fs_embed_get( + root_relative: &str, + file_path: &str, + includes: &[&str], + excludes: &[&str], +) -> Option { + let matcher = rust_embed::utils::PathMatcher::new(includes, excludes); + if !matcher.is_path_included(file_path) { + return None; + } + let root = dev_repo_root() + .expect("dev asset loading requires running from within the checkout") + .join(root_relative); + rust_embed::utils::read_file_from_fs(&root.join(file_path)).ok() +} + +/// A `rust_embed` asset source that embeds files in release builds and reads them +/// from the checkout at runtime in dev builds (edits show up on the next launch +/// with no rebuild). One invocation replaces the previous pairing of a +/// `#[cfg(not(debug_assertions))] #[derive(RustEmbed)]` struct with a separate +/// dev macro, and keeps a single source of truth for the include/exclude globs. +/// +/// It expands to both arms: +/// * Release (`not(debug_assertions)`): `#[derive(RustEmbed)]` embedding +/// `crate_relative` at build time, with the given `include`/`exclude` globs. +/// * Dev (`debug_assertions`): a runtime filesystem source rooted at +/// `root_relative`, applying those same globs through rust_embed's own matcher. +/// +/// Two paths are required because the arms resolve from different bases: the +/// release derive reads `crate_relative` relative to the crate's `Cargo.toml` +/// at build time (rust_embed's rule), while the dev arm resolves `root_relative` +/// relative to the repository root at runtime via [`dev_repo_root`]. Baking the +/// build-time path into the dev artifact would point at the wrong checkout from +/// another worktree and is rejected by corgi, whose sandbox requires +/// checkout-independent output. +/// +/// ```ignore +/// util::fs_embed! { +/// pub struct Assets, +/// crate_relative = "../../assets", +/// root_relative = "assets", +/// include = ["fonts/**/*", "themes/**/*", "*.md"], +/// exclude = ["themes/src/*", "*.DS_Store"], +/// } +/// ``` +#[macro_export] +macro_rules! fs_embed { + ( + $vis:vis struct $name:ident, + crate_relative = $crate_relative:literal, + root_relative = $root_relative:literal + $(, include = [$($include:literal),* $(,)?])? + $(, exclude = [$($exclude:literal),* $(,)?])? + $(,)? + ) => { + // `crate_path` points the derive's generated code at util's re-export so + // the caller needs no direct `rust_embed` dependency. + #[cfg(not(debug_assertions))] + #[derive($crate::__rust_embed::RustEmbed)] + #[crate_path = "::util::__rust_embed"] + #[folder = $crate_relative] + $($(#[include = $include])*)? + $($(#[exclude = $exclude])*)? + $vis struct $name; + + #[cfg(debug_assertions)] + $vis struct $name; + + // Mirror the derive's public surface: inherent `get`/`iter` (callable + // without the trait in scope) plus the trait impl (for generic bounds + // like `util::asset_str` and `handlebars::register_embed_templates`), so + // the two arms are interchangeable at call sites. + #[cfg(debug_assertions)] + impl $name { + pub fn get( + file_path: &str, + ) -> ::core::option::Option<$crate::__rust_embed::EmbeddedFile> { + $crate::__fs_embed_get( + $root_relative, + file_path, + &[$($($include),*)?], + &[$($($exclude),*)?], + ) + } + + pub fn iter( + ) -> impl ::core::iter::Iterator> + 'static + { + $crate::__fs_embed_iter( + $root_relative, + &[$($($include),*)?], + &[$($($exclude),*)?], + ) + } + } + + #[cfg(debug_assertions)] + impl $crate::__rust_embed::RustEmbed for $name { + fn get( + file_path: &str, + ) -> ::core::option::Option<$crate::__rust_embed::EmbeddedFile> { + <$name>::get(file_path) + } + + fn iter() -> $crate::__rust_embed::Filenames { + $crate::__fs_embed_iter( + $root_relative, + &[$($($include),*)?], + &[$($($exclude),*)?], + ) + } + } + }; +} + pub trait RangeExt { fn sorted(&self) -> Self; fn to_inclusive(&self) -> RangeInclusive; From 2ce2701dba58b9fce77392bdd244f0d2033401ff Mon Sep 17 00:00:00 2001 From: Ali Date: Mon, 31 Aug 2026 18:47:00 +0000 Subject: [PATCH 25/45] util: Prevent panic when parsing a malformed shell variable (#63446) # Objective `to_cmd_variable` and `to_powershell_variable` removed the last byte of a `${...}` argument assuming it was the closing brace, without checking one was there ```rust // If the input starts with "${", remove the trailing "}" format!("$env:{}", &var_str[..var_str.len() - 1]) ``` # Solution Use strip_suffix('}') and pass the input through when it isn't a variable reference ## Testing added tests to cover this To reproduce on Windows, add a context server with a malformed argument to settings.json: ``` "context_servers": { "crash-repro": { "command": "does-not-matter", "args": ["${"] } } } ```` Opening the agent panel and zed will crash Release Notes: - Fixed a panic when converting a malformed `${` shell variable reference on Windows --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/util/src/shell.rs | 56 +++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/crates/util/src/shell.rs b/crates/util/src/shell.rs index 985d352..5b91ec5 100644 --- a/crates/util/src/shell.rs +++ b/crates/util/src/shell.rs @@ -245,13 +245,13 @@ impl ShellKind { fn to_cmd_variable(input: &str) -> String { if let Some(var_str) = input.strip_prefix("${") { - if var_str.find(':').is_none() { - // If the input starts with "${", remove the trailing "}" - format!("%{}%", &var_str[..var_str.len() - 1]) - } else { + match var_str.strip_suffix('}') { + Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => { + format!("%{var_name}%") + } // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, // which will result in the task failing to run in such cases. - input.into() + _ => input.into(), } } else if let Some(var_str) = input.strip_prefix('$') { // If the input starts with "$", directly append to "$env:" @@ -264,13 +264,13 @@ impl ShellKind { fn to_powershell_variable(input: &str) -> String { if let Some(var_str) = input.strip_prefix("${") { - if var_str.find(':').is_none() { - // If the input starts with "${", remove the trailing "}" - format!("$env:{}", &var_str[..var_str.len() - 1]) - } else { + match var_str.strip_suffix('}') { + Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => { + format!("$env:{var_name}") + } // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, // which will result in the task failing to run in such cases. - input.into() + _ => input.into(), } } else if let Some(var_str) = input.strip_prefix('$') { // If the input starts with "$", directly append to "$env:" @@ -954,4 +954,40 @@ mod tests { assert!(quoted.contains("O''Brien")); } } + + #[test] + fn test_to_shell_variable() { + assert_eq!( + ShellKind::PowerShell.to_shell_variable("${FOO}"), + "$env:FOO" + ); + assert_eq!(ShellKind::Pwsh.to_shell_variable("${FOO}"), "$env:FOO"); + assert_eq!(ShellKind::Cmd.to_shell_variable("${FOO}"), "%FOO%"); + assert_eq!(ShellKind::Nushell.to_shell_variable("${FOO}"), "$env.FOO"); + assert_eq!(ShellKind::Posix.to_shell_variable("${FOO}"), "${FOO}"); + + assert_eq!(ShellKind::PowerShell.to_shell_variable("$FOO"), "$env:FOO"); + assert_eq!( + ShellKind::PowerShell.to_shell_variable("${ζ—₯本}"), + "$env:ζ—₯本" + ); + assert_eq!( + ShellKind::PowerShell.to_shell_variable("${FOO:-bar}"), + "${FOO:-bar}" + ); + } + + #[test] + fn test_to_shell_variable_malformed_is_passed_through() { + for input in ["${", "${FOO", "${cafΓ©", "${}", "${ζ—₯本"] { + for shell_kind in [ + ShellKind::PowerShell, + ShellKind::Pwsh, + ShellKind::Cmd, + ShellKind::Nushell, + ] { + assert_eq!(shell_kind.to_shell_variable(input), input); + } + } + } } From 4b6819b5f5cabed99b4751ff89d4f910e668ffcb Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Tue, 1 Sep 2026 12:43:44 +0000 Subject: [PATCH 26/45] gpui_web: Tighten up web keyboard feel (#63533) Fixes a few edge cases where we weren't correctly tracking whether to hide the keyboard when touch events happen Also forces a synchronous render on web after the viewport resizes (which happens when the keyboard appears/disappears). This prevents a 1 frame delay where the browser would use the old bitmap, stretched to the new size --- crates/gpui/src/elements/div.rs | 26 ++++++++++++++------------ crates/gpui/src/window.rs | 4 ++++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index a312e86..173bd27 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -3309,13 +3309,14 @@ impl Interactivity { if let Some(group_hover) = self.group_hover_style.as_ref() { let is_group_hovered = if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) { - group_hitbox_id.is_hovered(window) + !window.last_input_was_touch() && group_hitbox_id.is_hovered(window) } else if let Some(element_state) = element_state.as_ref() { - element_state - .hover_state - .as_ref() - .map(|state| state.borrow().group) - .unwrap_or(false) + !window.last_input_was_touch() + && element_state + .hover_state + .as_ref() + .map(|state| state.borrow().group) + .unwrap_or(false) } else { false }; @@ -3327,13 +3328,14 @@ impl Interactivity { if let Some(hover_style) = self.hover_style.as_ref() { let is_hovered = if let Some(hitbox) = hitbox { - hitbox.is_hovered(window) + !window.last_input_was_touch() && hitbox.is_hovered(window) } else if let Some(element_state) = element_state.as_ref() { - element_state - .hover_state - .as_ref() - .map(|state| state.borrow().element) - .unwrap_or(false) + !window.last_input_was_touch() + && element_state + .hover_state + .as_ref() + .map(|state| state.borrow().element) + .unwrap_or(false) } else { false }; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 01a2255..ba389aa 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2864,6 +2864,10 @@ impl Window { self.last_input_modality == InputModality::Keyboard } + pub(crate) fn last_input_was_touch(&self) -> bool { + self.last_input_modality == InputModality::Touch + } + /// The current state of the keyboard's capslock pub fn capslock(&self) -> Capslock { self.capslock From 4bca204b347042908e885014dbf4ee7909752a89 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 1 Sep 2026 14:20:13 +0000 Subject: [PATCH 27/45] gpui: Lock touch scrolling to the dominant axis (#63553) Touch pans currently preserve small amounts of movement on both axes, including in the synthetic momentum generated after release. In a nested scrolling layout, a mostly vertical fling that starts over a horizontally scrollable child can therefore move the child horizontally while its ancestor moves vertically. This locks each touch pan to the dominant axis of its accumulated movement when it crosses the touch-slop threshold. Initial movement, predicted updates, release correction, velocity, and momentum are projected onto that axis, so every scroll listener sees the same one-dimensional gesture for its full lifetime. The existing trackpad and wheel behavior remains unchanged; the new rule is applied by the portable raw-touch recognizer before it emits semantic scroll events. Testing: - `cargo nextest run -p gpui --lib gestures::tests` - `cargo fmt --all -- --check` - `./script/clippy -p gpui` - Built Delta Web for `wasm32-unknown-unknown` with this GPUI worktree and reached a successful Trunk release build Release Notes: - N/A --- crates/gpui/src/gestures.rs | 156 ++++++++++++++++++++++++++++++------ 1 file changed, 132 insertions(+), 24 deletions(-) diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index edf7895..b3c6259 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -24,6 +24,21 @@ use crate::{ const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); +fn dominant_axis(delta: Point) -> Axis { + if delta.x.abs() <= delta.y.abs() { + Axis::Vertical + } else { + Axis::Horizontal + } +} + +fn lock_delta_to_axis(delta: &mut Point, axis: Axis) { + match axis { + Axis::Vertical => delta.x = Pixels::ZERO, + Axis::Horizontal => delta.y = Pixels::ZERO, + } +} + /// Tracks the dominant axis across the events in a scroll gesture. #[derive(Clone, Copy, Debug, Default)] pub struct OngoingScroll { @@ -66,11 +81,7 @@ impl OngoingScroll { .is_none_or(|last_event| now.duration_since(last_event) >= SCROLL_EVENT_SEPARATION); let mut axis = self.axis; if starts_new_gesture { - axis = if x <= y { - Some(Axis::Vertical) - } else { - Some(Axis::Horizontal) - }; + axis = Some(dominant_axis(*delta)); } else if x.max(y) >= UNLOCK_LOWER_BOUND { match axis { Some(Axis::Vertical) if x > y && x >= y * UNLOCK_PERCENT => { @@ -85,10 +96,8 @@ impl OngoingScroll { self.last_event = Some(now); self.axis = axis; - match axis { - Some(Axis::Vertical) => delta.x = Pixels::ZERO, - Some(Axis::Horizontal) => delta.y = Pixels::ZERO, - None => {} + if let Some(axis) = axis { + lock_delta_to_axis(delta, axis); } } } @@ -463,7 +472,10 @@ enum TouchGestureState { Pending(ActiveTouch), /// The touch exceeded `touch_slop`: it is a pan until it ends, and its /// movement flows out as scroll events. - Panning(ActiveTouch), + Panning { + touch: ActiveTouch, + axis: Axis, + }, } struct ActiveTouch { @@ -493,6 +505,7 @@ struct Momentum { position: Point, /// Unit vector of the release velocity. direction: Point, + axis: Axis, /// Release speed in pixels per second. speed: f32, started_at: Instant, @@ -532,9 +545,9 @@ impl TouchGestureRecognizer { Point::default(), TouchPhase::Ended, ))); - true + Some(momentum.axis) } else { - false + None }; if matches!(self.state, TouchGestureState::Idle) { let mut velocity_tracker = VelocityTracker::default(); @@ -545,7 +558,7 @@ impl TouchGestureRecognizer { emitted_position: event.position, velocity_tracker, }; - if caught_fling { + if let Some(axis) = caught_fling { // A touch that catches a fling is a drag from the // first pixel: waiting out the slop would freeze the // content mid-scroll and then jump. It can also never @@ -556,7 +569,7 @@ impl TouchGestureRecognizer { Point::default(), TouchPhase::Started, ))); - self.state = TouchGestureState::Panning(touch); + self.state = TouchGestureState::Panning { touch, axis }; } else { self.state = TouchGestureState::Pending(touch); } @@ -571,28 +584,32 @@ impl TouchGestureRecognizer { // step: the content catches up to the finger instead // of losing the slop distance. let target = event.predicted_position.unwrap_or(event.position); + let axis = dominant_axis(accumulated); + let mut delta = target - touch.start_position; + lock_delta_to_axis(&mut delta, axis); touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, - target - touch.start_position, + delta, TouchPhase::Started, ))); - self.state = TouchGestureState::Panning(touch); + self.state = TouchGestureState::Panning { touch, axis }; } else { self.state = TouchGestureState::Pending(touch); } } - TouchGestureState::Panning(mut touch) if touch.id == event.id => { + TouchGestureState::Panning { mut touch, axis } if touch.id == event.id => { touch.velocity_tracker.push(now, event.position); let target = event.predicted_position.unwrap_or(event.position); - let delta = target - touch.emitted_position; + let mut delta = target - touch.emitted_position; + lock_delta_to_axis(&mut delta, axis); touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, delta, TouchPhase::Moved, ))); - self.state = TouchGestureState::Panning(touch); + self.state = TouchGestureState::Panning { touch, axis }; } other => self.state = other, }, @@ -629,7 +646,7 @@ impl TouchGestureRecognizer { }, }); } - TouchGestureState::Panning(touch) if touch.id == event.id => { + TouchGestureState::Panning { touch, axis } if touch.id == event.id => { // The release deliberately contributes no velocity // sample: it usually repeats the last movement's position // with a later timestamp, which would dilute the @@ -643,13 +660,18 @@ impl TouchGestureRecognizer { .is_none_or(|latest| { now.duration_since(latest) > VELOCITY_ASSUME_STOPPED_GAP }); - let velocity = if finger_stopped { + let mut velocity = if finger_stopped { Point::default() } else { touch.velocity_tracker.velocity() }; + match axis { + Axis::Vertical => velocity.x = 0., + Axis::Horizontal => velocity.y = 0., + } let speed = (velocity.x.powi(2) + velocity.y.powi(2)).sqrt(); let mut release_delta = event.position - touch.emitted_position; + lock_delta_to_axis(&mut release_delta, axis); if speed >= self.tuning.min_fling_velocity { let direction = point(velocity.x / speed, velocity.y / speed); let speed = speed.min(MAX_FLING_VELOCITY); @@ -676,6 +698,7 @@ impl TouchGestureRecognizer { self.momentum = Some(Momentum { position: touch.start_position, direction, + axis, speed, started_at: now, duration, @@ -693,7 +716,7 @@ impl TouchGestureRecognizer { }, TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) { TouchGestureState::Pending(touch) if touch.id == event.id => {} - TouchGestureState::Panning(touch) if touch.id == event.id => { + TouchGestureState::Panning { touch, .. } if touch.id == event.id => { recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, Point::default(), @@ -1086,6 +1109,51 @@ mod tests { assert_eq!(scroll.touch_phase, TouchPhase::Ended); } + #[test] + fn touch_pan_stays_locked_to_its_initial_dominant_axis() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 104., 120.), + now + Duration::from_millis(16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(20.))); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 134., 125.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(5.))); + } + + #[test] + fn touch_pan_locks_to_horizontal_axis() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 120., 104.), + now + Duration::from_millis(16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(20.), px(0.))); + } + #[test] fn predicted_positions_lead_the_pan_but_totals_converge_on_release() { let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); @@ -1096,7 +1164,7 @@ mod tests { // The first pan step scrolls to the predicted position, not the raw one. let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.); - moved.predicted_position = Some(point(px(100.), px(128.))); + moved.predicted_position = Some(point(px(106.), px(128.))); let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16)); let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { panic!("expected scroll, got {recognized:?}"); @@ -1106,7 +1174,7 @@ mod tests { // The next step is measured from where the previous prediction left // the content, so an overshoot is paid back here. let mut moved = touch_event(touch, TouchPhase::Moved, 100., 130.); - moved.predicted_position = Some(point(px(100.), px(134.))); + moved.predicted_position = Some(point(px(104.), px(134.))); let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32)); let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { panic!("expected scroll, got {recognized:?}"); @@ -1249,6 +1317,46 @@ mod tests { assert!(recognizer.tick_momentum_at(time).is_none()); } + #[test] + fn diagonal_release_flings_only_on_locked_axis() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now); + for step in 1..=5 { + let recognized = recognizer.handle_event_at( + &touch_event( + touch, + TouchPhase::Moved, + 100. + step as f32 * 3., + 300. - step as f32 * 20., + ), + now + Duration::from_millis(step * 16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)).x, px(0.)); + } + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 115., 200.), + now + Duration::from_millis(6 * 16), + ); + assert!(recognizer.has_momentum()); + + let mut time = now + Duration::from_millis(6 * 16); + while recognizer.has_momentum() { + time += Duration::from_millis(16); + if let Some(RecognizedTouchGesture::Scroll(scroll)) = recognizer.tick_momentum_at(time) + { + let delta = scroll.delta.pixel_delta(px(16.)); + assert_eq!(delta.x, px(0.)); + assert!(delta.y <= px(0.)); + } + } + } + #[test] fn slow_release_does_not_start_momentum() { let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); From d15d0ce48212548c9c89e62bfdc5c258f6f37169 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 1 Sep 2026 15:53:08 +0000 Subject: [PATCH 28/45] gpui_linux: Prevent panic while reporting Wayland errors (#63159) `wayland-backend` falls back to `eprintln!` when its optional `log` feature is disabled. If a Wayland connection fails while standard error is unavailable, that fallback panics while trying to report the original error. Closes ZED-8BW Enable the existing `log` feature on `gpui_linux`'s direct `wayland-backend` dependency. `gpui_linux` already depends on the `log` facade, so this introduces no new logging system; it routes backend errors through the configured logger and avoids the secondary standard-error panic. Testing performed: Release Notes: - Fixed a Linux crash when reporting a Wayland connection error while standard error is unavailable. --- crates/gpui_linux/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index a5fa0a7..76d010e 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -95,6 +95,7 @@ calloop-wayland-source = { version = "0.4.1", optional = true } wayland-backend = { version = "0.3.15", features = [ "client_system", "dlopen", + "log", ], optional = true } wayland-client = { version = "0.31.11", optional = true } wayland-cursor = { version = "0.31.11", optional = true } From 29b78f78f0e9923c3d454c156bdee127e4a716c5 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 1 Sep 2026 16:28:50 +0000 Subject: [PATCH 29/45] gpui: Add long-press gesture recognition (#63561) GPUI's touch gesture recognizer handles taps and pans, but its long-press event was only a placeholder. Applications therefore could not distinguish a deliberate press-and-hold from a tap or scroll using the portable touch path. This adds deadline-driven long-press recognition and dispatches a phased event stream through GPUI's existing capture and bubble listener path. A listener claims the gesture by preventing the initial event's default behavior and capturing it for an entity. Unclaimed gestures remain eligible for the existing tap and pan behaviors, while claimed gestures receive movement and termination events without also producing taps or scrolling. The timer is cancelled when the pending gesture resolves. `gpui_web` now translates reusable browser pointer identifiers into touch identifiers that remain unique for each touch lifetime, preventing an old timer from acting on a newer touch. Native macOS trackpad scrolling remains on its existing `ScrollWheelEvent` path and does not enter touch gesture recognition. Testing performed: - `cargo fmt --all -- --check` - `cargo nextest run -p gpui --lib long_press` (10 passed) - `RUSTC_BOOTSTRAP=1 cargo check -p gpui_web --target wasm32-unknown-unknown` Release Notes: - N/A --- crates/gpui/src/gestures.rs | 301 ++++++++++++++++++++++++++++++--- crates/gpui/src/interactive.rs | 13 +- crates/gpui/src/window.rs | 282 +++++++++++++++++++++++++++++- 3 files changed, 568 insertions(+), 28 deletions(-) diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index b3c6259..f11b1a7 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -18,8 +18,9 @@ use scheduler::Instant; use smallvec::SmallVec; use crate::{ - Axis, IsZero, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Pixels, Point, ScrollDelta, - ScrollWheelEvent, TouchEvent, TouchId, TouchPhase, point, px, + Axis, GestureEvent, InputEvent, IsZero, Modifiers, MouseButton, MouseDownEvent, MouseEvent, + MouseUpEvent, Pixels, PlatformInput, Point, ScrollDelta, ScrollWheelEvent, TouchEvent, TouchId, + TouchPhase, point, px, seal::Sealed, }; const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); @@ -375,19 +376,36 @@ impl GestureKinds { }; } -/// A long-press gesture, mobile's context-menu trigger. -/// -/// A bare long press is surfaced as a [`ClickEvent`](crate::ClickEvent) with -/// `long_press: true`, delivered to aux-click listeners alongside right -/// clicks. This event is the raw hook for elements that need the gesture -/// itself (e.g. long-press to start a drag); the registration API ships -/// together with the gesture arena. -#[derive(Clone, Debug, Default)] +/// A phased long-press gesture recognized from a touch. +#[derive(Clone, Debug)] pub struct LongPressEvent { - /// The position of the touch that was recognized as a long press. + /// The phase of the long press. + pub phase: TouchPhase, + /// The position where the touch started. + pub start_position: Point, + /// The touch's current position. pub position: Point, } +impl Default for LongPressEvent { + fn default() -> Self { + Self { + phase: TouchPhase::Started, + start_position: Point::default(), + position: Point::default(), + } + } +} + +impl Sealed for LongPressEvent {} +impl InputEvent for LongPressEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::LongPress(self) + } +} +impl GestureEvent for LongPressEvent {} +impl MouseEvent for LongPressEvent {} + /// Platform gesture recognition services. /// /// If your mobile platform supports native gesture recognition, use this @@ -442,8 +460,8 @@ const VELOCITY_MAX_SAMPLES: usize = 20; /// [`ClickEvent::Touch`](crate::ClickEvent), which keeps every existing /// mouse-driven behavior (click listeners, caret placement, double-tap /// selection) working before elements grow a direct tap-delivery path. -/// Long-press and pinch recognition are not implemented yet, and additional -/// touches are ignored while one is being recognized. +/// Pinch recognition is not implemented yet, and additional touches are ignored +/// while one is being recognized. pub(crate) struct TouchGestureRecognizer { tuning: GestureTuning, state: TouchGestureState, @@ -463,24 +481,32 @@ pub(crate) enum RecognizedTouchGesture { down: MouseDownEvent, up: MouseUpEvent, }, + LongPress(LongPressEvent), } enum TouchGestureState { Idle, /// The touch is still within `touch_slop` of where it started: it can /// still resolve into either a tap or a pan. - Pending(ActiveTouch), + Pending { + touch: ActiveTouch, + deadline: Instant, + offered: bool, + }, /// The touch exceeded `touch_slop`: it is a pan until it ends, and its /// movement flows out as scroll events. Panning { touch: ActiveTouch, axis: Axis, }, + LongPressing(ActiveTouch), } struct ActiveTouch { id: TouchId, start_position: Point, + /// The latest raw position reported for this touch. + last_position: Point, /// The position pan output has scrolled to so far. While panning this /// may run ahead of the raw touch by the event's predicted position; /// the release event targets the raw position again, so the total @@ -555,6 +581,7 @@ impl TouchGestureRecognizer { let touch = ActiveTouch { id: event.id, start_position: event.position, + last_position: event.position, emitted_position: event.position, velocity_tracker, }; @@ -571,13 +598,22 @@ impl TouchGestureRecognizer { ))); self.state = TouchGestureState::Panning { touch, axis }; } else { - self.state = TouchGestureState::Pending(touch); + self.state = TouchGestureState::Pending { + touch, + deadline: now + self.tuning.long_press_duration, + offered: false, + }; } } } TouchPhase::Moved => match mem::replace(&mut self.state, TouchGestureState::Idle) { - TouchGestureState::Pending(mut touch) if touch.id == event.id => { + TouchGestureState::Pending { + mut touch, + deadline, + offered, + } if touch.id == event.id => { touch.velocity_tracker.push(now, event.position); + touch.last_position = event.position; let accumulated = event.position - touch.start_position; if accumulated.magnitude() > f64::from(self.tuning.touch_slop) { // Carry the full movement so far into the first scroll @@ -595,11 +631,16 @@ impl TouchGestureRecognizer { ))); self.state = TouchGestureState::Panning { touch, axis }; } else { - self.state = TouchGestureState::Pending(touch); + self.state = TouchGestureState::Pending { + touch, + deadline, + offered, + }; } } TouchGestureState::Panning { mut touch, axis } if touch.id == event.id => { touch.velocity_tracker.push(now, event.position); + touch.last_position = event.position; let target = event.predicted_position.unwrap_or(event.position); let mut delta = target - touch.emitted_position; lock_delta_to_axis(&mut delta, axis); @@ -611,10 +652,19 @@ impl TouchGestureRecognizer { ))); self.state = TouchGestureState::Panning { touch, axis }; } + TouchGestureState::LongPressing(mut touch) if touch.id == event.id => { + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent { + phase: TouchPhase::Moved, + start_position: touch.start_position, + position: event.position, + })); + self.state = TouchGestureState::LongPressing(touch); + } other => self.state = other, }, TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) { - TouchGestureState::Pending(touch) if touch.id == event.id => { + TouchGestureState::Pending { touch, .. } if touch.id == event.id => { let tap_count = match &self.last_tap { Some(tap) if now.duration_since(tap.time) <= self.tuning.multi_tap_interval @@ -712,10 +762,17 @@ impl TouchGestureRecognizer { TouchPhase::Ended, ))); } + TouchGestureState::LongPressing(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent { + phase: TouchPhase::Ended, + start_position: touch.start_position, + position: event.position, + })); + } other => self.state = other, }, TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) { - TouchGestureState::Pending(touch) if touch.id == event.id => {} + TouchGestureState::Pending { touch, .. } if touch.id == event.id => {} TouchGestureState::Panning { touch, .. } if touch.id == event.id => { recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, @@ -723,12 +780,61 @@ impl TouchGestureRecognizer { TouchPhase::Cancelled, ))); } + TouchGestureState::LongPressing(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent { + phase: TouchPhase::Cancelled, + start_position: touch.start_position, + position: event.position, + })); + } other => self.state = other, }, } recognized } + pub(crate) fn pending_long_press(&self) -> Option<(TouchId, Duration)> { + let TouchGestureState::Pending { + touch, + deadline, + offered: false, + } = &self.state + else { + return None; + }; + Some((touch.id, deadline.saturating_duration_since(Instant::now()))) + } + + pub(crate) fn offer_long_press(&mut self, id: TouchId) -> Option { + let TouchGestureState::Pending { touch, offered, .. } = &mut self.state else { + return None; + }; + if touch.id != id || *offered { + return None; + } + *offered = true; + Some(RecognizedTouchGesture::LongPress(LongPressEvent { + phase: TouchPhase::Started, + start_position: touch.start_position, + position: touch.last_position, + })) + } + + pub(crate) fn resolve_long_press(&mut self, claimed: bool) { + if !claimed { + return; + } + let state = mem::replace(&mut self.state, TouchGestureState::Idle); + self.state = match state { + TouchGestureState::Pending { + touch, + offered: true, + .. + } => TouchGestureState::LongPressing(touch), + other => other, + }; + } + pub(crate) fn has_momentum(&self) -> bool { self.momentum.is_some() } @@ -1699,6 +1805,163 @@ mod tests { ); } + #[test] + fn claimed_long_press_emits_phased_stream_without_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now); + let Some(RecognizedTouchGesture::LongPress(started)) = recognizer.offer_long_press(touch) + else { + panic!("expected long press"); + }; + assert_eq!(started.phase, TouchPhase::Started); + assert_eq!(started.start_position, point(px(10.), px(20.))); + recognizer.resolve_long_press(true); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 12., 21.), + now + Duration::from_millis(510), + ); + let [RecognizedTouchGesture::LongPress(moved)] = moved.as_slice() else { + panic!("expected moved long press, got {moved:?}"); + }; + assert_eq!(moved.phase, TouchPhase::Moved); + + let ended = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 12., 21.), + now + Duration::from_millis(520), + ); + let [RecognizedTouchGesture::LongPress(ended)] = ended.as_slice() else { + panic!("expected ended long press, got {ended:?}"); + }; + assert_eq!(ended.phase, TouchPhase::Ended); + } + + #[test] + fn unclaimed_long_press_remains_a_tap_candidate() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now); + assert!(recognizer.offer_long_press(touch).is_some()); + recognizer.resolve_long_press(false); + + let ended = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 10., 20.), + now + Duration::from_millis(510), + ); + assert!(matches!( + ended.as_slice(), + [RecognizedTouchGesture::Tap { .. }] + )); + } + + #[test] + fn unclaimed_long_press_can_still_become_a_pan() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now); + assert!(recognizer.offer_long_press(touch).is_some()); + recognizer.resolve_long_press(false); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 20., 0.), + now + Duration::from_millis(510), + ); + assert!(matches!( + moved.as_slice(), + [RecognizedTouchGesture::Scroll(ScrollWheelEvent { + touch_phase: TouchPhase::Started, + .. + })] + )); + } + + #[test] + fn long_press_offer_is_one_shot_and_specific_to_pending_touch() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 0., 0.)); + + assert!( + recognizer + .handle_event(&touch_event(TouchId(2), TouchPhase::Moved, 20., 0.)) + .is_empty() + ); + assert!(recognizer.offer_long_press(TouchId(2)).is_none()); + assert!(recognizer.offer_long_press(touch).is_some()); + assert!(recognizer.offer_long_press(touch).is_none()); + } + + #[test] + fn long_press_cannot_be_offered_after_pending_touch_resolves() { + for phase in [TouchPhase::Ended, TouchPhase::Cancelled, TouchPhase::Moved] { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now); + let position = if phase == TouchPhase::Moved { 20. } else { 0. }; + recognizer.handle_event_at( + &touch_event(touch, phase, position, 0.), + now + Duration::from_millis(10), + ); + assert!(recognizer.offer_long_press(touch).is_none()); + } + } + + #[test] + fn claimed_long_press_emits_cancelled_for_its_touch_only() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 4., 5.)); + assert!(recognizer.offer_long_press(touch).is_some()); + recognizer.resolve_long_press(true); + + assert!( + recognizer + .handle_event(&touch_event(TouchId(2), TouchPhase::Cancelled, 9., 9.)) + .is_empty() + ); + let cancelled = recognizer.handle_event(&touch_event(touch, TouchPhase::Cancelled, 6., 7.)); + let [RecognizedTouchGesture::LongPress(cancelled)] = cancelled.as_slice() else { + panic!("expected cancelled long press, got {cancelled:?}"); + }; + assert_eq!(cancelled.phase, TouchPhase::Cancelled); + assert_eq!(cancelled.start_position, point(px(4.), px(5.))); + assert_eq!(cancelled.position, point(px(6.), px(7.))); + } + + #[test] + fn unrelated_touch_cannot_end_or_cancel_pending_touch() { + for phase in [TouchPhase::Ended, TouchPhase::Cancelled] { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 4., 5.)); + + assert!( + recognizer + .handle_event(&touch_event(TouchId(2), phase, 9., 9.)) + .is_empty() + ); + assert!(recognizer.offer_long_press(touch).is_some()); + } + } + + #[test] + fn completed_touch_id_cannot_claim_replacement_touch() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let completed_touch = TouchId(1); + let replacement_touch = TouchId(2); + recognizer.handle_event(&touch_event(completed_touch, TouchPhase::Started, 0., 0.)); + recognizer.handle_event(&touch_event(completed_touch, TouchPhase::Cancelled, 0., 0.)); + recognizer.handle_event(&touch_event(replacement_touch, TouchPhase::Started, 5., 5.)); + + assert!(recognizer.offer_long_press(completed_touch).is_none()); + assert!(recognizer.offer_long_press(replacement_touch).is_some()); + } + fn touch_event(id: TouchId, phase: TouchPhase, x: f32, y: f32) -> TouchEvent { TouchEvent { id, diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index be77a1a..cfbf2bc 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -1,6 +1,6 @@ use crate::{ - Bounds, Capslock, Context, Empty, IntoElement, Keystroke, Modifiers, Pixels, Point, Render, - Window, point, seal::Sealed, + Bounds, Capslock, Context, Empty, IntoElement, Keystroke, LongPressEvent, Modifiers, Pixels, + Point, Render, Window, point, seal::Sealed, }; use smallvec::SmallVec; use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf}; @@ -103,8 +103,8 @@ pub enum TouchPhase { /// [`TouchPhase::Started`] through [`TouchPhase::Ended`] or /// [`TouchPhase::Cancelled`]. /// -/// The value is opaque and platform-defined; it is only guaranteed to be -/// stable for the duration of the touch and unique among concurrent touches. +/// The value is opaque and assigned by the platform. A platform window must +/// not reuse an identifier for a later touch. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TouchId(pub u64); @@ -789,6 +789,8 @@ pub enum PlatformInput { ScrollWheel(ScrollWheelEvent), /// A pinch gesture was performed. Pinch(PinchEvent), + /// A long-press gesture recognized from touch input. + LongPress(LongPressEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), /// A raw touch event on a touch screen. @@ -808,6 +810,7 @@ impl PlatformInput { PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::Pinch(event) => Some(event), + PlatformInput::LongPress(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), PlatformInput::Touch(_) => None, } @@ -825,6 +828,7 @@ impl PlatformInput { PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, PlatformInput::Pinch(_) => None, + PlatformInput::LongPress(_) => None, PlatformInput::FileDrop(_) => None, PlatformInput::Touch(_) => None, } @@ -844,6 +848,7 @@ impl PlatformInput { PlatformInput::MouseExited(_) => "mouse_exited", PlatformInput::ScrollWheel(_) => "scroll_wheel", PlatformInput::Pinch(_) => "pinch", + PlatformInput::LongPress(_) => "long_press", PlatformInput::FileDrop(_) => "file_drop", PlatformInput::Touch(_) => "touch", } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index ba389aa..212f816 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1204,6 +1204,8 @@ pub struct Window { last_input_modality: InputModality, touch_gestures: TouchGestureRecognizer, touch_prediction_enabled: bool, + long_press_timer: Option>, + long_press_capture: Option, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -1901,6 +1903,8 @@ impl Window { .map_or_else(GestureTuning::default, |gestures| gestures.tuning()), ), touch_prediction_enabled: true, + long_press_timer: None, + long_press_capture: None, refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -2853,6 +2857,20 @@ impl Window { self.captured_hitbox } + /// Captures the current long press for the given entity. + /// + /// The capture is released when the gesture ends or is cancelled, or when + /// a replacement touch begins. A listener must also call + /// [`Self::prevent_default`] on the started event to claim the gesture. + pub fn capture_long_press(&mut self, entity: &Entity) { + self.long_press_capture = Some(entity.entity_id()); + } + + /// Returns whether the given entity has captured the current long press. + pub fn has_long_press_capture(&self, entity: &Entity) -> bool { + self.long_press_capture == Some(entity.entity_id()) + } + /// The current state of the keyboard's modifiers pub fn modifiers(&self) -> Modifiers { self.modifiers @@ -5372,6 +5390,17 @@ impl Window { } }, PlatformInput::Touch(touch) => PlatformInput::Touch(touch), + PlatformInput::LongPress(long_press) => { + self.mouse_position = if long_press.phase == crate::TouchPhase::Started { + long_press.start_position + } else { + long_press.position + }; + if long_press.phase == crate::TouchPhase::Started { + self.long_press_capture = None; + } + PlatformInput::LongPress(long_press) + } PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, }; @@ -5382,6 +5411,17 @@ impl Window { } else if let Some(touch_event) = event.touch_event() { self.dispatch_touch_event(touch_event, cx); } + if let PlatformInput::LongPress(long_press) = &event { + match long_press.phase { + crate::TouchPhase::Started if !self.default_prevented => { + self.long_press_capture = None; + } + crate::TouchPhase::Ended | crate::TouchPhase::Cancelled => { + self.long_press_capture = None; + } + crate::TouchPhase::Started | crate::TouchPhase::Moved => {} + } + } // Must run after the move is dispatched: the platform owns the gesture afterwards, so this // is the last chance for drag listeners to see the pointer leave and reset their state. @@ -5453,11 +5493,21 @@ impl Window { event.predicted_position = None; } let recognized_gestures = self.touch_gestures.handle_event(&event); + if event.phase == crate::TouchPhase::Started + && self.touch_gestures.pending_long_press().is_some() + { + self.long_press_capture = None; + } let mut tapped = false; for gesture in recognized_gestures { tapped |= matches!(gesture, RecognizedTouchGesture::Tap { .. }); self.dispatch_recognized_touch_gesture(gesture, cx); } + if event.phase == crate::TouchPhase::Started { + self.schedule_long_press_timer(cx); + } else if self.touch_gestures.pending_long_press().is_none() { + self.long_press_timer.take(); + } // The platform's touch-release handler may inspect the input handler // as soon as this dispatch returns (the web platform decides virtual // keyboard visibility there, inside the user gesture). Input handlers @@ -5485,9 +5535,51 @@ impl Window { cx.propagate_event = true; self.dispatch_mouse_event(&up, cx); } + RecognizedTouchGesture::LongPress(long_press) => { + self.mouse_position = if long_press.phase == crate::TouchPhase::Started { + long_press.start_position + } else { + long_press.position + }; + cx.propagate_event = true; + self.default_prevented = false; + let started = long_press.phase == crate::TouchPhase::Started; + let ended = matches!( + long_press.phase, + crate::TouchPhase::Ended | crate::TouchPhase::Cancelled + ); + self.dispatch_mouse_event(&long_press, cx); + if started { + let claimed = self.default_prevented; + self.touch_gestures.resolve_long_press(claimed); + if !claimed { + self.long_press_capture = None; + } + } + if ended { + self.long_press_capture = None; + } + } } } + fn schedule_long_press_timer(&mut self, cx: &mut App) { + self.long_press_timer.take(); + let Some((touch_id, duration)) = self.touch_gestures.pending_long_press() else { + return; + }; + self.long_press_timer = Some(self.spawn(cx, async move |cx| { + cx.background_executor.timer(duration).await; + cx.update(move |window, cx| { + window.long_press_timer.take(); + if let Some(gesture) = window.touch_gestures.offer_long_press(touch_id) { + window.dispatch_recognized_touch_gesture(gesture, cx); + } + }) + .log_err(); + })); + } + fn schedule_touch_momentum_tick(&mut self) { self.on_next_frame(|window, cx| { if let Some(gesture) = window.touch_gestures.tick_momentum() { @@ -7230,15 +7322,16 @@ mod tests { cell::{Cell, RefCell}, path::PathBuf, rc::Rc, + time::Duration, }; use crate::{ - AnyWindowHandle, AppContext as _, Bounds, Context, DragMoveEvent, Empty, + AnyWindowHandle, AppContext as _, Bounds, Context, DispatchPhase, DragMoveEvent, Empty, ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, - InputEvent as _, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, - MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions, - StatefulInteractiveElement as _, Styled, TestAppContext, Window, WindowAppearance, - WindowOptions, canvas, div, point, px, size, + InputEvent as _, InteractiveElement as _, IntoElement, LongPressEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions, + StatefulInteractiveElement as _, Styled, TestAppContext, TouchEvent, TouchId, TouchPhase, + Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, }; struct EmptyView; @@ -7940,4 +8033,183 @@ mod tests { .unwrap(); assert_eq!(b_focus_count.get(), 1); } + + #[gpui::test] + fn long_press_is_claimed_only_when_started_prevents_default(cx: &mut TestAppContext) { + for response in [ + LongPressResponse::PreventDefault, + LongPressResponse::StopPropagation, + LongPressResponse::None, + ] { + let phases = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let phases = phases.clone(); + move |_, _| LongPressListener { phases, response } + }); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Started, 0.); + cx.executor().advance_clock(Duration::from_millis(501)); + cx.executor().run_until_parked(); + window + .update(cx, |_, window, _| { + assert_eq!( + window.long_press_capture.is_some(), + response == LongPressResponse::PreventDefault + ); + }) + .unwrap(); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Moved, 2.); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Ended, 2.); + window + .update(cx, |_, window, _| { + assert!(window.long_press_capture.is_none()); + }) + .unwrap(); + + let phases = phases.borrow(); + if response == LongPressResponse::PreventDefault { + assert_eq!( + phases.as_slice(), + [TouchPhase::Started, TouchPhase::Moved, TouchPhase::Ended] + ); + } else { + assert_eq!(phases.as_slice(), [TouchPhase::Started]); + } + } + } + + #[gpui::test] + fn stale_default_prevention_does_not_claim_long_press(cx: &mut TestAppContext) { + let phases = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let phases = phases.clone(); + move |_, _| LongPressListener { + phases, + response: LongPressResponse::None, + } + }); + window + .update(cx, |_, window, _| { + window.prevent_default(); + }) + .unwrap(); + + dispatch_touch(window, cx, TouchId(1), TouchPhase::Started, 0.); + cx.executor().advance_clock(Duration::from_millis(501)); + cx.executor().run_until_parked(); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Moved, 2.); + + assert_eq!(phases.borrow().as_slice(), [TouchPhase::Started]); + } + + #[gpui::test] + fn resolved_touch_cancels_scheduled_long_press(cx: &mut TestAppContext) { + for (phase, position) in [ + (TouchPhase::Ended, 0.), + (TouchPhase::Cancelled, 0.), + (TouchPhase::Moved, 20.), + ] { + let phases = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let phases = phases.clone(); + move |_, _| LongPressListener { + phases, + response: LongPressResponse::PreventDefault, + } + }); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Started, 0.); + dispatch_touch(window, cx, TouchId(1), phase, position); + cx.executor().advance_clock(Duration::from_millis(501)); + cx.executor().run_until_parked(); + + assert!(phases.borrow().is_empty(), "{phase:?} allowed long press"); + } + } + + #[gpui::test] + fn stale_long_press_timer_cannot_affect_replacement_touch(cx: &mut TestAppContext) { + let phases = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let phases = phases.clone(); + move |_, _| LongPressListener { + phases, + response: LongPressResponse::PreventDefault, + } + }); + let first_touch = TouchId(1); + dispatch_touch(window, cx, first_touch, TouchPhase::Started, 0.); + cx.executor().advance_clock(Duration::from_millis(250)); + dispatch_touch(window, cx, first_touch, TouchPhase::Cancelled, 0.); + dispatch_touch(window, cx, TouchId(2), TouchPhase::Started, 10.); + + cx.executor().advance_clock(Duration::from_millis(251)); + cx.executor().run_until_parked(); + assert!(phases.borrow().is_empty()); + + cx.executor().advance_clock(Duration::from_millis(250)); + cx.executor().run_until_parked(); + assert_eq!(phases.borrow().as_slice(), [TouchPhase::Started]); + } + + #[derive(Clone, Copy, PartialEq)] + enum LongPressResponse { + PreventDefault, + StopPropagation, + None, + } + + struct LongPressListener { + phases: Rc>>, + response: LongPressResponse, + } + + impl Render for LongPressListener { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity(); + let phases = self.phases.clone(); + let response = self.response; + canvas( + |_, _, _| {}, + move |_, _, window, _| { + window.on_mouse_event(move |event: &LongPressEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + phases.borrow_mut().push(event.phase); + match response { + LongPressResponse::PreventDefault => { + window.capture_long_press(&entity); + window.prevent_default(); + } + LongPressResponse::StopPropagation => cx.stop_propagation(), + LongPressResponse::None => {} + } + }); + }, + ) + } + } + + fn dispatch_touch( + window: crate::WindowHandle, + cx: &mut TestAppContext, + id: TouchId, + phase: TouchPhase, + x: f32, + ) { + window + .update(cx, |_, window, cx| { + window.dispatch_event( + TouchEvent { + id, + phase, + position: point(px(x), px(0.)), + predicted_position: None, + force: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + } } From 0bb34688843d11bc87128af712e277723885dba7 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Sep 2026 19:59:31 +0000 Subject: [PATCH 30/45] util: Make fs_embed! compatible with rust-embed's debug-embed feature (#63577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zed's `fs_embed!` macro hand-implements rust-embed's `RustEmbed` trait in its dev arm so debug builds read assets from the checkout at runtime (introduced with corgi support in #63396). That trait returns `rust_embed::Filenames`, an enum with exactly one variant per compilation context: `Dynamic` when rust-embed's `debug-embed` feature is off, `Embedded` when it is on. The dev arm constructed `Filenames::Dynamic` unconditionally. Cargo unifies features across the entire build graph, so a downstream workspace that consumes Zed's crates by git dependency and enables `rust-embed/debug-embed` anywhere (Delta did, for self-contained debug binaries) changes the enum's shape for `util` itself, and `util` stops compiling with `E0599: no variant named Dynamic`. Zed's own CI can never catch this because nothing in Zed's graph enables the feature; it only surfaces in embedding workspaces, where it blocks any dependency bump past #63396. This change makes the combination supported. `util` gains a forwarding `debug-embed` feature, and `__fs_embed_iter` now has two implementations under cfgs that mirror rust-embed's variant availability: the existing boxed-iterator path when the feature is off, and an `Embedded` path when it is on, which scans the checkout once per embed site and leaks the cached name list (the `Embedded` variant requires `'static` names; a per-process list matches the macro's documented contract that dev edits appear on the next launch, and `get` still reads file contents fresh on every call). Consumers must enable the feature through `util/debug-embed` rather than directly on rust-embed β€” enabling it behind util's back still breaks, which only rust-embed itself could fix; the feature's documentation says so. A new test exercises the dev arm's `iter` and `get` through the macro, and running util's suite with `--features debug-embed` covers the other variant; both configurations pass 133 tests, plus clippy and fmt. Release Notes: - N/A --------- Co-authored-by: Conrad Irwin --- crates/util/Cargo.toml | 6 +++ crates/util/src/util.rs | 116 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index bebac88..dc0ca93 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -16,6 +16,12 @@ doctest = true [features] test-support = ["rand", "util_macros", "path/test-support"] +# Embed `fs_embed!` assets at compile time even in debug builds. Workspaces +# that want rust-embed's `debug-embed` behavior must enable it through this +# feature rather than on `rust-embed` directly: the feature changes which +# variants of `rust_embed::Filenames` exist, and `fs_embed!`'s dev arm can +# only construct the right one when this crate sees the same feature state. +debug-embed = ["rust-embed/debug-embed"] [dependencies] anyhow.workspace = true diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 36aeb20..0403dbe 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -684,26 +684,75 @@ pub mod __rust_embed { /// directory that passes the same rust_embed include/exclude globs the /// release derive uses, so the dev and release file sets are identical. Reuses /// rust_embed's own matcher rather than reimplementing glob semantics. -#[cfg(debug_assertions)] +/// +/// `rust_embed::Filenames` has exactly one variant per compilation context: +/// `Dynamic` without the `debug-embed` feature, `Embedded` with it. Cargo +/// unifies features across the whole build graph, so a consumer workspace +/// enabling `debug-embed` changes the enum this crate sees; each variant +/// below compiles only in the context where its `Filenames` variant exists. +#[cfg(all(debug_assertions, not(feature = "debug-embed")))] #[doc(hidden)] pub fn __fs_embed_iter( root_relative: &str, includes: &[&str], excludes: &[&str], ) -> rust_embed::Filenames { - let Some(root) = dev_repo_root().map(|root| root.join(root_relative)) else { - return rust_embed::Filenames::Dynamic(Box::new(std::iter::empty::< - std::borrow::Cow<'static, str>, - >())); - }; - let matcher = rust_embed::utils::PathMatcher::new(includes, excludes); let names: Vec> = - rust_embed::utils::get_files(root.to_string_lossy().into_owned(), matcher) - .map(|entry| std::borrow::Cow::Owned(entry.rel_path)) + fs_embed_file_names(root_relative, includes, excludes) + .into_iter() + .map(std::borrow::Cow::Owned) .collect(); rust_embed::Filenames::Dynamic(Box::new(names.into_iter())) } +/// The `debug-embed` arm of [`fs_embed!`]'s dev `iter`. The `Embedded` +/// variant requires `'static` names, so the checkout is scanned once per +/// embed site and the resulting name list is leaked and cached. The list +/// staying fixed for the life of the process matches the macro's contract +/// that dev edits show up on the next launch; file contents still come from +/// `get`, which reads the filesystem on every call. +#[cfg(all(debug_assertions, feature = "debug-embed"))] +#[doc(hidden)] +pub fn __fs_embed_iter( + root_relative: &str, + includes: &[&str], + excludes: &[&str], +) -> rust_embed::Filenames { + use std::collections::BTreeMap; + use std::sync::Mutex; + + /// Keyed by root and globs: distinct embed sites may share a root with + /// different filters. + type EmbedSiteKey = (String, Vec, Vec); + static LEAKED_NAMES: Mutex> = + Mutex::new(BTreeMap::new()); + + let key = ( + root_relative.to_string(), + includes.iter().map(|glob| glob.to_string()).collect(), + excludes.iter().map(|glob| glob.to_string()).collect(), + ); + let names = *LEAKED_NAMES.lock().unwrap().entry(key).or_insert_with(|| { + let names: Vec<&'static str> = fs_embed_file_names(root_relative, includes, excludes) + .into_iter() + .map(|name| &*name.leak()) + .collect(); + names.leak() + }); + rust_embed::Filenames::Embedded(names.iter()) +} + +#[cfg(debug_assertions)] +fn fs_embed_file_names(root_relative: &str, includes: &[&str], excludes: &[&str]) -> Vec { + let Some(root) = dev_repo_root().map(|root| root.join(root_relative)) else { + return Vec::new(); + }; + let matcher = rust_embed::utils::PathMatcher::new(includes, excludes); + rust_embed::utils::get_files(root.to_string_lossy().into_owned(), matcher) + .map(|entry| entry.rel_path) + .collect() +} + /// Backs the dev arm of [`fs_embed!`]'s `get`: reads a single file from the /// checkout, returning `None` when the include/exclude globs filter it out so /// `get` matches release's embedded set exactly. @@ -813,7 +862,9 @@ macro_rules! fs_embed { <$name>::get(file_path) } - fn iter() -> $crate::__rust_embed::Filenames { + fn iter( + ) -> impl ::core::iter::Iterator> + 'static + { $crate::__fs_embed_iter( $root_relative, &[$($($include),*)?], @@ -985,6 +1036,51 @@ impl From> for ConnectionResult { mod tests { use super::*; + /// Exercises `fs_embed!`'s dev arm, whose `RustEmbed::iter` implementation + /// must construct whichever `rust_embed::Filenames` variant the current + /// feature state provides. Running util's tests with and without the + /// `debug-embed` feature covers both variants. + #[cfg(debug_assertions)] + #[test] + fn test_fs_embed_dev_arm_iter_and_get() { + let inherent_names: Vec<_> = FsEmbedTestAssets::iter().collect(); + assert!( + inherent_names.iter().any(|name| name == "util.rs"), + "iter should list files matching the include globs, got {inherent_names:?}" + ); + assert!( + !inherent_names.iter().any(|name| name.ends_with(".toml")), + "iter should filter files excluded by the globs, got {inherent_names:?}" + ); + + let trait_names: Vec<_> = + ::iter().collect(); + assert_eq!(inherent_names, trait_names); + + let file = FsEmbedTestAssets::get("util.rs").expect("util.rs should be readable"); + assert!( + std::str::from_utf8(&file.data) + .expect("util.rs should be utf-8") + .contains("fs_embed"), + "get should read the real file contents" + ); + assert!( + FsEmbedTestAssets::get("Cargo.toml").is_none(), + "get should filter files excluded by the globs" + ); + } + + // Dev arm only: the release arm's derive names this crate as + // `::util`, which does not resolve from util's own unit tests. + #[cfg(debug_assertions)] + crate::fs_embed! { + struct FsEmbedTestAssets, + crate_relative = "src", + root_relative = "crates/util/src", + include = ["*.rs"], + exclude = ["test/**/*"], + } + #[test] fn test_parse_os_release() { let os_release = From bbea843754ed64f6b7e05703339a0178977633dc Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Sep 2026 21:39:47 +0000 Subject: [PATCH 31/45] Relicense zlog, ztracing, and ztracing_macro under Apache-2.0 (#63573) Relicenses `zlog`, `ztracing`, and `ztracing_macro` from GPL-3.0-or-later to Apache-2.0. GPUI depends on `ztracing`, which depends on `zlog`, so these crates have to be non-copyleft for GPUI to ship as a permissively licensed project. - Switched the `license` field to `Apache-2.0` in all three manifests. - Added the `LICENSE-APACHE` symlink to `zlog`, and dropped the `LICENSE-GPL` symlinks from all three. `ztracing` and `ztracing_macro` already carried both symlinks; now the on-disk license matches the manifest. - Declared `license = "Apache-2.0"` for `gpui_util`, which had a `LICENSE-APACHE` symlink but no `license` field. `script/check-licenses` doesn't catch that, since it only inspects symlinks. No source changes; none of these crates carry GPL headers or in-source license references. Checked the rest of the subtree while here: the only first-party crates reachable from `ztracing` via `cargo tree -e normal` are `collections`, `gpui_util`, `zlog`, and `ztracing_macro`, all Apache-2.0 now. Third-party deps (`tracing`, `tracing-subscriber`, `chrono`, `log`, `anyhow`, `tracy-client`) are MIT/Apache. `script/check-licenses` passes. Release Notes: - N/A --- crates/gpui_shared_string/Cargo.toml | 1 + crates/gpui_util/Cargo.toml | 1 + crates/zlog/Cargo.toml | 2 +- crates/zlog/LICENSE-APACHE | 1 + crates/zlog/LICENSE-GPL | 1 - crates/ztracing/Cargo.toml | 2 +- crates/ztracing/LICENSE-GPL | 1 - crates/ztracing_macro/Cargo.toml | 2 +- crates/ztracing_macro/LICENSE-GPL | 1 - 9 files changed, 6 insertions(+), 6 deletions(-) create mode 120000 crates/zlog/LICENSE-APACHE delete mode 120000 crates/zlog/LICENSE-GPL delete mode 120000 crates/ztracing/LICENSE-GPL delete mode 120000 crates/ztracing_macro/LICENSE-GPL diff --git a/crates/gpui_shared_string/Cargo.toml b/crates/gpui_shared_string/Cargo.toml index e1e975a..36ce41a 100644 --- a/crates/gpui_shared_string/Cargo.toml +++ b/crates/gpui_shared_string/Cargo.toml @@ -3,6 +3,7 @@ name = "gpui_shared_string" version = "0.1.0" publish.workspace = true edition.workspace = true +license = "Apache-2.0" [lib] path = "gpui_shared_string.rs" diff --git a/crates/gpui_util/Cargo.toml b/crates/gpui_util/Cargo.toml index cad692b..06810be 100644 --- a/crates/gpui_util/Cargo.toml +++ b/crates/gpui_util/Cargo.toml @@ -3,6 +3,7 @@ name = "gpui_util" version = "0.1.0" publish.workspace = true edition.workspace = true +license = "Apache-2.0" [dependencies] log.workspace = true diff --git a/crates/zlog/Cargo.toml b/crates/zlog/Cargo.toml index 2799592..46f2cff 100644 --- a/crates/zlog/Cargo.toml +++ b/crates/zlog/Cargo.toml @@ -3,7 +3,7 @@ name = "zlog" version = "0.1.0" edition.workspace = true publish.workspace = true -license = "GPL-3.0-or-later" +license = "Apache-2.0" [lints] workspace = true diff --git a/crates/zlog/LICENSE-APACHE b/crates/zlog/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/zlog/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/zlog/LICENSE-GPL b/crates/zlog/LICENSE-GPL deleted file mode 120000 index 89e542f..0000000 --- a/crates/zlog/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ztracing/Cargo.toml b/crates/ztracing/Cargo.toml index 81d3fc0..e823785 100644 --- a/crates/ztracing/Cargo.toml +++ b/crates/ztracing/Cargo.toml @@ -3,7 +3,7 @@ name = "ztracing" version = "0.1.0" edition.workspace = true publish.workspace = true -license = "GPL-3.0-or-later" +license = "Apache-2.0" [lints] workspace = true diff --git a/crates/ztracing/LICENSE-GPL b/crates/ztracing/LICENSE-GPL deleted file mode 120000 index 89e542f..0000000 --- a/crates/ztracing/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ztracing_macro/Cargo.toml b/crates/ztracing_macro/Cargo.toml index dbd7adc..5420bc6 100644 --- a/crates/ztracing_macro/Cargo.toml +++ b/crates/ztracing_macro/Cargo.toml @@ -3,7 +3,7 @@ name = "ztracing_macro" version = "0.1.0" edition.workspace = true publish.workspace = true -license = "GPL-3.0-or-later" +license = "Apache-2.0" [lib] proc-macro = true diff --git a/crates/ztracing_macro/LICENSE-GPL b/crates/ztracing_macro/LICENSE-GPL deleted file mode 120000 index 89e542f..0000000 --- a/crates/ztracing_macro/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file From 48e02d4bd70f73cc8d37dc622992247784a49d6e Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 1 Sep 2026 21:42:01 +0000 Subject: [PATCH 32/45] util: Embed fs_embed assets with debug-embed (#63583) Zed's `fs_embed!` macro normally reads assets from the checkout in debug builds. The new `util/debug-embed` feature is intended to compile those assets into debug binaries, but it previously changed only the `rust_embed::Filenames` variant while leaving file loading on the runtime path. That breaks downstream workspaces such as Delta: the runtime loader identifies Delta as the repository root, then looks for Zed grammar assets under Delta's checkout. Tests consequently panic when language configuration files cannot be found. This makes `util/debug-embed` select a compile-time `RustEmbed` expansion in every profile. Builds without the feature retain live filesystem loading in debug mode. The runtime iterator no longer depends on a feature-specific `rust_embed::Filenames` variant, so Cargo feature unification remains safe when another package enables `rust-embed/debug-embed` directly. The regression fixture uses a deliberately invalid runtime path when `debug-embed` is enabled, so it proves that the embedded path is used rather than passing only because the Zed checkout contains the test assets. Testing performed: - `cargo fmt --all -- --check` - `cargo nextest run -p util --lib` - `cargo nextest run -p util --lib --features debug-embed` - `cargo check -p remote_server --features debug-embed` - `./script/clippy -p util` - Delta's previously failing `tools::edit_file::tests::test_edit_file_rejects_scratch_paths_as_read_only` against this PR's head commit Release Notes: - N/A --- crates/util/Cargo.toml | 6 +- crates/util/src/util.rs | 171 +++++++++++++++++++--------------------- 2 files changed, 81 insertions(+), 96 deletions(-) diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index dc0ca93..d1f3c0c 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -16,11 +16,7 @@ doctest = true [features] test-support = ["rand", "util_macros", "path/test-support"] -# Embed `fs_embed!` assets at compile time even in debug builds. Workspaces -# that want rust-embed's `debug-embed` behavior must enable it through this -# feature rather than on `rust-embed` directly: the feature changes which -# variants of `rust_embed::Filenames` exist, and `fs_embed!`'s dev arm can -# only construct the right one when this crate sees the same feature state. +# Embed `fs_embed!` assets at compile time even in debug builds. debug-embed = ["rust-embed/debug-embed"] [dependencies] diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 0403dbe..88d8746 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -1,3 +1,6 @@ +#[cfg(test)] +extern crate self as util; + #[cfg(not(target_family = "wasm"))] pub mod archive; #[cfg(not(target_family = "wasm"))] @@ -684,65 +687,19 @@ pub mod __rust_embed { /// directory that passes the same rust_embed include/exclude globs the /// release derive uses, so the dev and release file sets are identical. Reuses /// rust_embed's own matcher rather than reimplementing glob semantics. -/// -/// `rust_embed::Filenames` has exactly one variant per compilation context: -/// `Dynamic` without the `debug-embed` feature, `Embedded` with it. Cargo -/// unifies features across the whole build graph, so a consumer workspace -/// enabling `debug-embed` changes the enum this crate sees; each variant -/// below compiles only in the context where its `Filenames` variant exists. #[cfg(all(debug_assertions, not(feature = "debug-embed")))] #[doc(hidden)] pub fn __fs_embed_iter( root_relative: &str, includes: &[&str], excludes: &[&str], -) -> rust_embed::Filenames { - let names: Vec> = - fs_embed_file_names(root_relative, includes, excludes) - .into_iter() - .map(std::borrow::Cow::Owned) - .collect(); - rust_embed::Filenames::Dynamic(Box::new(names.into_iter())) -} - -/// The `debug-embed` arm of [`fs_embed!`]'s dev `iter`. The `Embedded` -/// variant requires `'static` names, so the checkout is scanned once per -/// embed site and the resulting name list is leaked and cached. The list -/// staying fixed for the life of the process matches the macro's contract -/// that dev edits show up on the next launch; file contents still come from -/// `get`, which reads the filesystem on every call. -#[cfg(all(debug_assertions, feature = "debug-embed"))] -#[doc(hidden)] -pub fn __fs_embed_iter( - root_relative: &str, - includes: &[&str], - excludes: &[&str], -) -> rust_embed::Filenames { - use std::collections::BTreeMap; - use std::sync::Mutex; - - /// Keyed by root and globs: distinct embed sites may share a root with - /// different filters. - type EmbedSiteKey = (String, Vec, Vec); - static LEAKED_NAMES: Mutex> = - Mutex::new(BTreeMap::new()); - - let key = ( - root_relative.to_string(), - includes.iter().map(|glob| glob.to_string()).collect(), - excludes.iter().map(|glob| glob.to_string()).collect(), - ); - let names = *LEAKED_NAMES.lock().unwrap().entry(key).or_insert_with(|| { - let names: Vec<&'static str> = fs_embed_file_names(root_relative, includes, excludes) - .into_iter() - .map(|name| &*name.leak()) - .collect(); - names.leak() - }); - rust_embed::Filenames::Embedded(names.iter()) +) -> impl Iterator> + 'static { + fs_embed_file_names(root_relative, includes, excludes) + .into_iter() + .map(std::borrow::Cow::Owned) } -#[cfg(debug_assertions)] +#[cfg(all(debug_assertions, not(feature = "debug-embed")))] fn fs_embed_file_names(root_relative: &str, includes: &[&str], excludes: &[&str]) -> Vec { let Some(root) = dev_repo_root().map(|root| root.join(root_relative)) else { return Vec::new(); @@ -756,7 +713,7 @@ fn fs_embed_file_names(root_relative: &str, includes: &[&str], excludes: &[&str] /// Backs the dev arm of [`fs_embed!`]'s `get`: reads a single file from the /// checkout, returning `None` when the include/exclude globs filter it out so /// `get` matches release's embedded set exactly. -#[cfg(debug_assertions)] +#[cfg(all(debug_assertions, not(feature = "debug-embed")))] #[doc(hidden)] pub fn __fs_embed_get( root_relative: &str, @@ -774,37 +731,31 @@ pub fn __fs_embed_get( rust_embed::utils::read_file_from_fs(&root.join(file_path)).ok() } -/// A `rust_embed` asset source that embeds files in release builds and reads them -/// from the checkout at runtime in dev builds (edits show up on the next launch -/// with no rebuild). One invocation replaces the previous pairing of a -/// `#[cfg(not(debug_assertions))] #[derive(RustEmbed)]` struct with a separate -/// dev macro, and keeps a single source of truth for the include/exclude globs. -/// -/// It expands to both arms: -/// * Release (`not(debug_assertions)`): `#[derive(RustEmbed)]` embedding -/// `crate_relative` at build time, with the given `include`/`exclude` globs. -/// * Dev (`debug_assertions`): a runtime filesystem source rooted at -/// `root_relative`, applying those same globs through rust_embed's own matcher. -/// -/// Two paths are required because the arms resolve from different bases: the -/// release derive reads `crate_relative` relative to the crate's `Cargo.toml` -/// at build time (rust_embed's rule), while the dev arm resolves `root_relative` -/// relative to the repository root at runtime via [`dev_repo_root`]. Baking the -/// build-time path into the dev artifact would point at the wrong checkout from -/// another worktree and is rejected by corgi, whose sandbox requires -/// checkout-independent output. -/// -/// ```ignore -/// util::fs_embed! { -/// pub struct Assets, -/// crate_relative = "../../assets", -/// root_relative = "assets", -/// include = ["fonts/**/*", "themes/**/*", "*.md"], -/// exclude = ["themes/src/*", "*.DS_Store"], -/// } -/// ``` +#[cfg(feature = "debug-embed")] +#[doc(hidden)] #[macro_export] -macro_rules! fs_embed { +macro_rules! __fs_embed { + ( + $vis:vis struct $name:ident, + crate_relative = $crate_relative:literal, + root_relative = $root_relative:literal + $(, include = [$($include:literal),* $(,)?])? + $(, exclude = [$($exclude:literal),* $(,)?])? + $(,)? + ) => { + #[derive($crate::__rust_embed::RustEmbed)] + #[crate_path = "::util::__rust_embed"] + #[folder = $crate_relative] + $($(#[include = $include])*)? + $($(#[exclude = $exclude])*)? + $vis struct $name; + }; +} + +#[cfg(not(feature = "debug-embed"))] +#[doc(hidden)] +#[macro_export] +macro_rules! __fs_embed { ( $vis:vis struct $name:ident, crate_relative = $crate_relative:literal, @@ -875,6 +826,40 @@ macro_rules! fs_embed { }; } +/// A `rust_embed` asset source that embeds files in release builds. Dev builds +/// read from the checkout at runtime unless the `debug-embed` feature is enabled, +/// in which case they embed the files too. +/// +/// It expands to one of these arms: +/// * Release (`not(debug_assertions)`): `#[derive(RustEmbed)]` embedding +/// `crate_relative` at build time, with the given `include`/`exclude` globs. +/// * Dev with `debug-embed`: the same compile-time embedding as release. +/// * Dev without `debug-embed`: a runtime filesystem source rooted at +/// `root_relative`; edits appear on the next launch without a rebuild. +/// +/// Two paths are required because the arms resolve from different bases: the +/// derive reads `crate_relative` relative to the crate's `Cargo.toml`, while the +/// runtime dev arm resolves `root_relative` relative to the repository root via +/// [`dev_repo_root`]. Baking the build-time path into that arm would point at the +/// wrong checkout from another worktree and is rejected by corgi, whose sandbox +/// requires checkout-independent output. +/// +/// ```ignore +/// util::fs_embed! { +/// pub struct Assets, +/// crate_relative = "../../assets", +/// root_relative = "assets", +/// include = ["fonts/**/*", "themes/**/*", "*.md"], +/// exclude = ["themes/src/*", "*.DS_Store"], +/// } +/// ``` +#[macro_export] +macro_rules! fs_embed { + ($($tokens:tt)*) => { + $crate::__fs_embed!($($tokens)*); + }; +} + pub trait RangeExt { fn sorted(&self) -> Self; fn to_inclusive(&self) -> RangeInclusive; @@ -1036,13 +1021,8 @@ impl From> for ConnectionResult { mod tests { use super::*; - /// Exercises `fs_embed!`'s dev arm, whose `RustEmbed::iter` implementation - /// must construct whichever `rust_embed::Filenames` variant the current - /// feature state provides. Running util's tests with and without the - /// `debug-embed` feature covers both variants. - #[cfg(debug_assertions)] #[test] - fn test_fs_embed_dev_arm_iter_and_get() { + fn test_fs_embed_iter_and_get() { let inherent_names: Vec<_> = FsEmbedTestAssets::iter().collect(); assert!( inherent_names.iter().any(|name| name == "util.rs"), @@ -1070,9 +1050,7 @@ mod tests { ); } - // Dev arm only: the release arm's derive names this crate as - // `::util`, which does not resolve from util's own unit tests. - #[cfg(debug_assertions)] + #[cfg(not(feature = "debug-embed"))] crate::fs_embed! { struct FsEmbedTestAssets, crate_relative = "src", @@ -1081,6 +1059,17 @@ mod tests { exclude = ["test/**/*"], } + // A consuming workspace does not contain a git dependency's files at this + // repository-relative path, so `debug-embed` must not consult it. + #[cfg(feature = "debug-embed")] + crate::fs_embed! { + struct FsEmbedTestAssets, + crate_relative = "src", + root_relative = "this-path-must-not-be-read", + include = ["*.rs"], + exclude = ["test/**/*"], + } + #[test] fn test_parse_os_release() { let os_release = From 58f588c7948dcd025ef0f2765e841ef8d1315f35 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:03:27 +0000 Subject: [PATCH 33/45] gpui: Record hang trigger class and set the release frame budget to 24ms (#63586) Hang incidents come in two classes that consumers could only tell apart by comparing `stall_ms` against the hang threshold, which is not recorded in the event: a single event over the threshold, or an interval whose cumulative foreground spend reached the frame budget. This PR makes the class explicit and re-tunes the budget. - `HangIncident` and `SerializedHangIncident` gain `trigger: HangTrigger` (`threshold` | `budget`). - The `Hang Incidents` telemetry event reports `threshold_incidents` and `budget_incidents` alongside the existing `total_incidents` (kept for existing queries; it is now the sum of the two). - The release frame budget moves from 8 ms to 24 ms. At 8 ms almost every busy 60 Hz frame qualified, so the budget class dominated incident counts without saying much. 24 ms is above every refresh period, so a budget incident now means the interval dropped at least one frame on any display. Frame smoothness below that is measured per-frame by `Frame Duration Report`. `budget_incidents` is the signal for lowering it further as hangs get fixed. Debug builds already used 100 ms and are unchanged. Part of a series on hang-telemetry data quality; #63588 handles withheld-frame `dirty_at`, and marking OS prompt waits so they stop registering as task-poll hangs is next. Release Notes: - N/A --- crates/gpui/src/profiler/hang.rs | 41 +++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/profiler/hang.rs b/crates/gpui/src/profiler/hang.rs index 9514931..7aea645 100644 --- a/crates/gpui/src/profiler/hang.rs +++ b/crates/gpui/src/profiler/hang.rs @@ -40,13 +40,30 @@ pub struct HangIncident { /// The interval the hangs occurred in, including all non-hang foreground /// work recorded alongside them. pub snapshot: FrameSnapshot, - /// The events that blocked the foreground for at least the detector's - /// threshold, longest first. When the incident was triggered by the - /// frame budget alone, no event crossed the threshold and this instead + /// Which detection rule qualified the interval. + pub trigger: HangTrigger, + /// For [`HangTrigger::Threshold`], the events that blocked the foreground + /// for at least the detector's threshold, longest first. For + /// [`HangTrigger::Budget`], no event crossed the threshold and this instead /// holds every event in the interval, longest first. pub contributors: Vec, } +/// The detection rule that qualified an interval as a [`HangIncident`]. +/// +/// Recorded explicitly so consumers can separate the two classes without +/// re-deriving them from `stall_ms`, which stops working whenever the +/// detector's thresholds change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HangTrigger { + /// A single event blocked the foreground for at least the hang threshold. + Threshold, + /// No single event crossed the threshold, but the interval's total + /// foreground spend reached the frame budget. + Budget, +} + impl HangDetector { /// Creates a detector reporting single events at or above `threshold` /// and intervals whose total foreground spend reached `frame_budget`. @@ -98,6 +115,8 @@ pub struct SerializedHangIncident { /// newly drawn frame finished platform submission (see /// [`HangDetector::first_present_at`]), otherwise `"steady"`. pub phase: &'static str, + /// `"threshold"` or `"budget"` (see [`HangTrigger`]). + pub trigger: HangTrigger, /// When the incident's active window started, in milliseconds since app /// startup: the sealing frame's first invalidation, or the earliest /// contributor's start when nothing was pending a repaint. Foreground @@ -250,6 +269,7 @@ impl SerializedHangIncident { Some(first_present_at) if active_start >= first_present_at => "steady", _ => "startup", }, + trigger: incident.trigger, start_ms: since_startup(active_start), active_ms: as_millis(active), stall_ms: incident @@ -407,7 +427,7 @@ impl HangIncident { .filter(|event| event.duration() >= threshold) .copied() .collect(); - if contributors.is_empty() { + let trigger = if contributors.is_empty() { if snapshot.journal_discontinuous { return None; } @@ -416,10 +436,14 @@ impl HangIncident { return None; } contributors = snapshot.events.clone(); - } + HangTrigger::Budget + } else { + HangTrigger::Threshold + }; contributors.sort_by_key(|event| std::cmp::Reverse(event.duration())); Some(Self { snapshot, + trigger, contributors, }) } @@ -449,7 +473,9 @@ mod tests { InputTiming, IntervalBoundary, PollSummary, PresentedFrame, SmallPollFlush, install_test_foreground_journal, record_present, }; - use super::{HangDetector, HangIncident, SerializedHangContributor, SerializedHangIncident}; + use super::{ + HangDetector, HangIncident, HangTrigger, SerializedHangContributor, SerializedHangIncident, + }; actions!(hang_test, [HangyAction]); @@ -567,6 +593,7 @@ mod tests { let serialized = SerializedHangIncident::convert(startup, &incident, 1, Some(at(50))); assert_eq!(serialized.phase, "steady"); + assert_eq!(serialized.trigger, HangTrigger::Threshold); // The frame's first invalidation anchors the active window, not the // interval start or the first contributor. assert_eq!(serialized.start_ms, 100.0); @@ -755,12 +782,14 @@ mod tests { let incident = HangIncident::detect(snapshot, HANG_THRESHOLD, FRAME_BUDGET) .expect("foreground spend exceeded the frame budget"); + assert_eq!(incident.trigger, HangTrigger::Budget); assert_eq!(incident.contributors.len(), 3); assert_eq!( incident.contributors[0].duration(), Duration::from_millis(8) ); let serialized = SerializedHangIncident::convert(startup, &incident, 8, Some(startup)); + assert_eq!(serialized.trigger, HangTrigger::Budget); assert_eq!(serialized.stall_ms, 8.0); assert_eq!(serialized.dirty_to_present_ms, Some(150.0)); assert_eq!(serialized.sealed_by, "present"); From d88dc913081ce42004b885707bdfcdd2cd3c8030 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 2 Sep 2026 18:21:25 +0000 Subject: [PATCH 34/45] Bump more dependencies (#63379) * 1st commit fixes bumps `quinn-proto` and `serde_with` to fix the dependabot alerts * 2nd commit deduplicates more dependencies `cargo tree -d` listing: 194 -> 187 top-level entries on the host target, 298 -> 287 unique duplicated crate-versions across all targets.
Deduplicated list ``` accesskit_consumer (v0.35.0, v0.37.0 -> v0.38.0) bindgen (v0.71.1, v0.72.1 -> v0.72.1) nix (v0.28.0, v0.29.0, v0.30.1 -> v0.28.0, v0.30.1) ordered-float (v2.10.1, v4.6.0 -> v5.5.0) quick-xml (v0.30.0, v0.37.5, v0.38.3, v0.39.3, v0.41.0 -> v0.30.0, v0.37.5, v0.39.3, v0.41.0) sysinfo (v0.31.4, v0.37.2 -> v0.31.4, v0.39.6) wasm-encoder (v0.252.0, v0.254.0 -> v0.254.0) wasmparser (v0.252.0, v0.254.0 -> v0.254.0) windows-registry (v0.4.0, v0.5.3, v0.6.1 -> v0.4.0, v0.6.1) ``` `ordered-float v4.6.0` remains in the lockfile only as an inactive optional dependency of `sea-query`; the compiled graph is fully deduplicated to v5.5.0.
Release Notes: - N/A --- crates/gpui/Cargo.toml | 2 +- crates/gpui_windows/Cargo.toml | 6 +++--- crates/media/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index a99c3cb..5715314 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -154,7 +154,7 @@ web-sys = { version = "0.3", features = ["console"] } embed-resource = { version = "3.0", optional = true } [target.'cfg(target_os = "macos")'.build-dependencies] -bindgen = "0.71" +bindgen = "0.72" [package.metadata.cargo-shear] ignored = [ diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 823e4d1..76f13ad 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -38,15 +38,15 @@ smallvec.workspace = true uuid.workspace = true windows.workspace = true windows-core.workspace = true -windows-numerics = "0.2" -windows-registry = "0.5" +windows-numerics = "0.3" +windows-registry.workspace = true [target.'cfg(target_os = "windows")'.dependencies.scap] workspace = true optional = true [target.'cfg(target_os = "windows")'.build-dependencies] -windows-registry = "0.5" +windows-registry.workspace = true [package.metadata.cargo-shear] ignored = ["scap"] diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 330fc26..908dd86 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -24,4 +24,4 @@ core-video.workspace = true objc.workspace = true [build-dependencies] -bindgen = "0.71" +bindgen = "0.72" From a4a4180bc1a07bbc3ae2204d8011ac84a3a3436e Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 2 Sep 2026 18:23:07 +0000 Subject: [PATCH 35/45] gpui_web: Drive IME mirror focus state from GPUI (#63629) Previously, we manually synchronized the keyboard state in response to tap events, but this meant that several common cases were unreliable. Now, we use GPUI's focused element as the source of truth, and use `sync_virtual_keyboard` in cases where they have gotten out of sync (i.e. a user manually dismisses a keyboard while the input is still focused). --- crates/gpui/src/input.rs | 19 +++++++++++++++++-- crates/gpui/src/platform/test/window.rs | 15 +++++++++++++-- crates/gpui/src/window.rs | 21 ++++++++++++++++++--- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index cbf6e40..4167aa0 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -288,11 +288,12 @@ mod tests { use super::*; use crate::{ AnyWindowHandle, AppContext as _, FocusHandle, InteractiveElement as _, IntoElement, - ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, canvas, div, + ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, + TextInputStateChange, canvas, div, }; #[gpui::test] - fn text_input_configuration_forwarded_only_on_change(cx: &mut TestAppContext) { + fn text_input_configuration_and_focus_state_are_forwarded_on_change(cx: &mut TestAppContext) { let custom = TextInputConfiguration { autocorrect: true, input_action: TextInputAction::Send, @@ -319,6 +320,7 @@ mod tests { test_window.text_input_configurations(), vec![TextInputConfiguration::default()] ); + assert!(test_window.text_input_state_changes().is_empty()); // Focusing the view routes its configuration to the platform. cx.update_window(window, |_, window, cx| { @@ -331,10 +333,15 @@ mod tests { test_window.text_input_configurations(), vec![TextInputConfiguration::default(), custom.clone()] ); + assert_eq!( + test_window.text_input_state_changes(), + vec![TextInputStateChange::FocusGained] + ); // Redrawing without a change forwards nothing. draw(cx); assert_eq!(test_window.text_input_configurations().len(), 2); + assert_eq!(test_window.text_input_state_changes().len(), 1); // Changing the configuration forwards the new value. let updated = TextInputConfiguration { @@ -354,6 +361,7 @@ mod tests { Some(&updated) ); assert_eq!(test_window.text_input_configurations().len(), 3); + assert_eq!(test_window.text_input_state_changes().len(), 1); // Losing focus reverts the platform to the default configuration. cx.update_window(window, |_, window, cx| window.blur(cx)) @@ -364,6 +372,13 @@ mod tests { Some(&TextInputConfiguration::default()) ); assert_eq!(test_window.text_input_configurations().len(), 4); + assert_eq!( + test_window.text_input_state_changes(), + vec![ + TextInputStateChange::FocusGained, + TextInputStateChange::FocusLost + ] + ); } struct ConfigurationTestView { diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 223a237..38926b6 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -2,8 +2,9 @@ use crate::{ AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, - PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TileId, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, + PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, + TextInputStateChange, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowParams, }; use collections::HashMap; use gpui_util::ResultExt as _; @@ -43,6 +44,7 @@ pub(crate) struct TestWindowState { frame_callback_pending: bool, input_handler: Option, text_input_configurations: Vec, + text_input_state_changes: Vec, is_fullscreen: bool, appearance: WindowAppearance, external_drag_files: Vec<(PathBuf, bool)>, @@ -106,6 +108,7 @@ impl TestWindow { frame_callback_pending: false, input_handler: None, text_input_configurations: Vec::new(), + text_input_state_changes: Vec::new(), is_fullscreen: false, appearance: WindowAppearance::Light, external_drag_files: Vec::new(), @@ -140,6 +143,10 @@ impl TestWindow { self.0.lock().text_input_configurations.clone() } + pub fn text_input_state_changes(&self) -> Vec { + self.0.lock().text_input_state_changes.clone() + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -269,6 +276,10 @@ impl PlatformWindow for TestWindow { self.0.lock().text_input_configurations.push(configuration); } + fn text_input_state_changed(&self, change: TextInputStateChange) { + self.0.lock().text_input_state_changes.push(change); + } + fn prompt( &self, _level: crate::PromptLevel, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 212f816..10bb1a5 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -19,7 +19,7 @@ use crate::{ 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, - TextInputConfiguration, + TextInputConfiguration, TextInputStateChange, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, @@ -1172,6 +1172,7 @@ pub struct Window { /// window, so that only actual changes are forwarded (reconfiguring a live /// input session can restart the IME connection). last_text_input_configuration: Option, + focused_text_input_active: bool, pub(crate) image_cache_stack: Vec, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, @@ -1870,6 +1871,7 @@ impl Window { element_opacity: 1.0, requested_autoscroll: None, last_text_input_configuration: None, + focused_text_input_active: false, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame_callbacks, @@ -2950,16 +2952,29 @@ impl Window { // 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 + let focused_text_input_active = if let Some(mut input_handler) = self .next_frame .input_handlers .iter_mut() .rev() .find_map(|h| h.take()) { + let accepts_text_input = input_handler.accepts_text_input(self, cx); self.platform_window.set_input_handler(input_handler); - } + accepts_text_input + } else { + false + }; self.apply_text_input_configuration(cx); + if focused_text_input_active != self.focused_text_input_active { + self.focused_text_input_active = focused_text_input_active; + self.platform_window + .text_input_state_changed(if focused_text_input_active { + TextInputStateChange::FocusGained + } else { + TextInputStateChange::FocusLost + }); + } self.layout_engine.as_mut().unwrap().clear(); self.text_system().finish_frame(); From 522e25304dee6b79d10ba666df795c18f755ac7c Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 2 Sep 2026 18:23:32 +0000 Subject: [PATCH 36/45] gpui: Prevent touch mispredict jitter (#63638) When scrolling, mispredicted touch events could cause scroll jitter. We now ignore predictions that are "travelling the wrong direction". --- crates/gpui/src/gestures.rs | 101 +++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index f11b1a7..c106208 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -40,6 +40,10 @@ fn lock_delta_to_axis(delta: &mut Point, axis: Axis) { } } +fn movements_oppose(left: Point, right: Point) -> bool { + f32::from(left.x) * f32::from(right.x) + f32::from(left.y) * f32::from(right.y) < 0. +} + /// Tracks the dominant axis across the events in a scroll gesture. #[derive(Clone, Copy, Debug, Default)] pub struct OngoingScroll { @@ -512,6 +516,9 @@ struct ActiveTouch { /// the release event targets the raw position again, so the total /// scrolled distance always converges to the finger's actual travel. emitted_position: Point, + /// Retained across stationary samples so prediction corrections cannot + /// reverse a pan when integer browser coordinates repeat. + last_movement: Point, velocity_tracker: VelocityTracker, } @@ -583,6 +590,7 @@ impl TouchGestureRecognizer { start_position: event.position, last_position: event.position, emitted_position: event.position, + last_movement: Point::default(), velocity_tracker, }; if let Some(axis) = caught_fling { @@ -619,10 +627,17 @@ impl TouchGestureRecognizer { // Carry the full movement so far into the first scroll // step: the content catches up to the finger instead // of losing the slop distance. - let target = event.predicted_position.unwrap_or(event.position); + let mut target = event.predicted_position.unwrap_or(event.position); let axis = dominant_axis(accumulated); let mut delta = target - touch.start_position; lock_delta_to_axis(&mut delta, axis); + touch.last_movement = accumulated; + lock_delta_to_axis(&mut touch.last_movement, axis); + if movements_oppose(delta, touch.last_movement) { + target = event.position; + delta = accumulated; + lock_delta_to_axis(&mut delta, axis); + } touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, @@ -639,11 +654,28 @@ impl TouchGestureRecognizer { } } TouchGestureState::Panning { mut touch, axis } if touch.id == event.id => { + let mut raw_delta = event.position - touch.last_position; + lock_delta_to_axis(&mut raw_delta, axis); + if raw_delta != Point::default() { + touch.last_movement = raw_delta; + } touch.velocity_tracker.push(now, event.position); touch.last_position = event.position; - let target = event.predicted_position.unwrap_or(event.position); + let mut target = event.predicted_position.unwrap_or(event.position); let mut delta = target - touch.emitted_position; lock_delta_to_axis(&mut delta, axis); + // Prediction error must not reverse content while the raw + // touch still advances. Fall back to the raw position so a + // real finger reversal remains responsive. + if movements_oppose(delta, touch.last_movement) { + target = event.position; + delta = target - touch.emitted_position; + lock_delta_to_axis(&mut delta, axis); + if movements_oppose(delta, touch.last_movement) { + target = touch.emitted_position; + delta = Point::default(); + } + } touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, @@ -853,8 +885,10 @@ impl TouchGestureRecognizer { .tuning .scroll_physics .fling_distance(momentum.speed, elapsed); - let step = distance - momentum.emitted_distance; - momentum.emitted_distance = distance; + // Prediction overshoot can start momentum ahead of its curve. Hold + // that position until the curve catches up instead of stepping back. + let step = (distance - momentum.emitted_distance).max(0.); + momentum.emitted_distance = momentum.emitted_distance.max(distance); let delta = point( px(momentum.direction.x * step), px(momentum.direction.y * step), @@ -1302,6 +1336,61 @@ mod tests { assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-4.))); } + #[test] + fn predicted_positions_do_not_emit_false_reversals() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.); + moved.predicted_position = Some(point(px(100.), px(130.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(30.))); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.); + moved.predicted_position = Some(point(px(100.), px(127.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!( + scroll.delta.pixel_delta(px(16.)), + Point::::default() + ); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.); + moved.predicted_position = Some(point(px(100.), px(126.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(40)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!( + scroll.delta.pixel_delta(px(16.)), + Point::::default() + ); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 132.); + moved.predicted_position = Some(point(px(100.), px(136.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(48)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.))); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 124.); + moved.predicted_position = Some(point(px(100.), px(140.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(64)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-12.))); + } + #[test] fn predicted_overshoot_folds_into_the_fling_without_scrolling_backwards() { let now = Instant::now(); @@ -1348,12 +1437,12 @@ mod tests { ); drain(&recognized, use_prediction); assert!(recognizer.has_momentum()); - let mut tick = now + Duration::from_millis(90); + let mut tick = now + Duration::from_millis(91); while recognizer.has_momentum() { - tick += Duration::from_millis(16); if let Some(gesture) = recognizer.tick_momentum_at(tick) { drain(&[gesture], use_prediction); } + tick += Duration::from_millis(16); } if use_prediction { From 15ff7902ac7363057542c95f8452da2536b7b091 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 2 Sep 2026 20:28:07 +0000 Subject: [PATCH 37/45] gpui: Add `TouchDragEvent` API (#63645) Adds `TouchDragEvent` and coresponding APIs. This allows web/touch-based code to capture long press events without having to go through scroll-like APIs --- crates/gpui/src/gestures.rs | 166 +++++++++++++++++++++++++++++++-- crates/gpui/src/interactive.rs | 7 +- crates/gpui/src/window.rs | 75 ++++++++++++++- 3 files changed, 234 insertions(+), 14 deletions(-) diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index c106208..ec61157 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -380,6 +380,27 @@ impl GestureKinds { }; } +/// A direct touch drag claimed by an element before touch input becomes a tap, +/// long press, or scrolling gesture. +#[derive(Clone, Debug)] +pub struct TouchDragEvent { + /// The phase of the touch drag. + pub phase: TouchPhase, + /// The position where the touch started. + pub start_position: Point, + /// The touch's current position. + pub position: Point, +} + +impl Sealed for TouchDragEvent {} +impl InputEvent for TouchDragEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::TouchDrag(self) + } +} +impl GestureEvent for TouchDragEvent {} +impl MouseEvent for TouchDragEvent {} + /// A phased long-press gesture recognized from a touch. #[derive(Clone, Debug)] pub struct LongPressEvent { @@ -485,6 +506,7 @@ pub(crate) enum RecognizedTouchGesture { down: MouseDownEvent, up: MouseUpEvent, }, + TouchDrag(TouchDragEvent), LongPress(LongPressEvent), } @@ -495,7 +517,8 @@ enum TouchGestureState { Pending { touch: ActiveTouch, deadline: Instant, - offered: bool, + long_press_offered: bool, + touch_drag_offered: bool, }, /// The touch exceeded `touch_slop`: it is a pan until it ends, and its /// movement flows out as scroll events. @@ -504,6 +527,7 @@ enum TouchGestureState { axis: Axis, }, LongPressing(ActiveTouch), + TouchDragging(ActiveTouch), } struct ActiveTouch { @@ -609,7 +633,8 @@ impl TouchGestureRecognizer { self.state = TouchGestureState::Pending { touch, deadline: now + self.tuning.long_press_duration, - offered: false, + long_press_offered: false, + touch_drag_offered: false, }; } } @@ -618,7 +643,8 @@ impl TouchGestureRecognizer { TouchGestureState::Pending { mut touch, deadline, - offered, + long_press_offered, + touch_drag_offered, } if touch.id == event.id => { touch.velocity_tracker.push(now, event.position); touch.last_position = event.position; @@ -649,7 +675,8 @@ impl TouchGestureRecognizer { self.state = TouchGestureState::Pending { touch, deadline, - offered, + long_press_offered, + touch_drag_offered, }; } } @@ -693,6 +720,15 @@ impl TouchGestureRecognizer { })); self.state = TouchGestureState::LongPressing(touch); } + TouchGestureState::TouchDragging(mut touch) if touch.id == event.id => { + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Moved, + start_position: touch.start_position, + position: event.position, + })); + self.state = TouchGestureState::TouchDragging(touch); + } other => self.state = other, }, TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) { @@ -801,6 +837,13 @@ impl TouchGestureRecognizer { position: event.position, })); } + TouchGestureState::TouchDragging(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Ended, + start_position: touch.start_position, + position: event.position, + })); + } other => self.state = other, }, TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) { @@ -819,6 +862,13 @@ impl TouchGestureRecognizer { position: event.position, })); } + TouchGestureState::TouchDragging(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Cancelled, + start_position: touch.start_position, + position: event.position, + })); + } other => self.state = other, }, } @@ -829,7 +879,8 @@ impl TouchGestureRecognizer { let TouchGestureState::Pending { touch, deadline, - offered: false, + long_press_offered: false, + .. } = &self.state else { return None; @@ -838,13 +889,18 @@ impl TouchGestureRecognizer { } pub(crate) fn offer_long_press(&mut self, id: TouchId) -> Option { - let TouchGestureState::Pending { touch, offered, .. } = &mut self.state else { + let TouchGestureState::Pending { + touch, + long_press_offered, + .. + } = &mut self.state + else { return None; }; - if touch.id != id || *offered { + if touch.id != id || *long_press_offered { return None; } - *offered = true; + *long_press_offered = true; Some(RecognizedTouchGesture::LongPress(LongPressEvent { phase: TouchPhase::Started, start_position: touch.start_position, @@ -860,13 +916,48 @@ impl TouchGestureRecognizer { self.state = match state { TouchGestureState::Pending { touch, - offered: true, + long_press_offered: true, .. } => TouchGestureState::LongPressing(touch), other => other, }; } + pub(crate) fn offer_touch_drag(&mut self, id: TouchId) -> Option { + let TouchGestureState::Pending { + touch, + touch_drag_offered, + .. + } = &mut self.state + else { + return None; + }; + if touch.id != id || *touch_drag_offered { + return None; + } + *touch_drag_offered = true; + Some(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Started, + start_position: touch.start_position, + position: touch.last_position, + })) + } + + pub(crate) fn resolve_touch_drag(&mut self, claimed: bool) { + if !claimed { + return; + } + let state = mem::replace(&mut self.state, TouchGestureState::Idle); + self.state = match state { + TouchGestureState::Pending { + touch, + touch_drag_offered: true, + .. + } => TouchGestureState::TouchDragging(touch), + other => other, + }; + } + pub(crate) fn has_momentum(&self) -> bool { self.momentum.is_some() } @@ -1894,6 +1985,63 @@ mod tests { ); } + #[test] + fn claimed_touch_drag_emits_phased_stream_without_pan_or_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now); + let Some(RecognizedTouchGesture::TouchDrag(started)) = recognizer.offer_touch_drag(touch) + else { + panic!("expected touch drag"); + }; + assert_eq!(started.phase, TouchPhase::Started); + assert_eq!(started.start_position, point(px(10.), px(20.))); + recognizer.resolve_touch_drag(true); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 40., 50.), + now + Duration::from_millis(10), + ); + let [RecognizedTouchGesture::TouchDrag(moved)] = moved.as_slice() else { + panic!("expected moved touch drag, got {moved:?}"); + }; + assert_eq!(moved.phase, TouchPhase::Moved); + assert_eq!(moved.position, point(px(40.), px(50.))); + + let ended = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 45., 55.), + now + Duration::from_millis(20), + ); + let [RecognizedTouchGesture::TouchDrag(ended)] = ended.as_slice() else { + panic!("expected ended touch drag, got {ended:?}"); + }; + assert_eq!(ended.phase, TouchPhase::Ended); + assert_eq!(ended.position, point(px(45.), px(55.))); + } + + #[test] + fn unclaimed_touch_drag_remains_a_pan_candidate() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now); + assert!(recognizer.offer_touch_drag(touch).is_some()); + recognizer.resolve_touch_drag(false); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 20., 0.), + now + Duration::from_millis(10), + ); + assert!(matches!( + moved.as_slice(), + [RecognizedTouchGesture::Scroll(ScrollWheelEvent { + touch_phase: TouchPhase::Started, + .. + })] + )); + } + #[test] fn claimed_long_press_emits_phased_stream_without_tap() { let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index cfbf2bc..913cf1b 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -1,6 +1,6 @@ use crate::{ Bounds, Capslock, Context, Empty, IntoElement, Keystroke, LongPressEvent, Modifiers, Pixels, - Point, Render, Window, point, seal::Sealed, + Point, Render, TouchDragEvent, Window, point, seal::Sealed, }; use smallvec::SmallVec; use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf}; @@ -791,6 +791,8 @@ pub enum PlatformInput { Pinch(PinchEvent), /// A long-press gesture recognized from touch input. LongPress(LongPressEvent), + /// A direct touch drag claimed by an element. + TouchDrag(TouchDragEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), /// A raw touch event on a touch screen. @@ -811,6 +813,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::Pinch(event) => Some(event), PlatformInput::LongPress(event) => Some(event), + PlatformInput::TouchDrag(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), PlatformInput::Touch(_) => None, } @@ -829,6 +832,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(_) => None, PlatformInput::Pinch(_) => None, PlatformInput::LongPress(_) => None, + PlatformInput::TouchDrag(_) => None, PlatformInput::FileDrop(_) => None, PlatformInput::Touch(_) => None, } @@ -849,6 +853,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(_) => "scroll_wheel", PlatformInput::Pinch(_) => "pinch", PlatformInput::LongPress(_) => "long_press", + PlatformInput::TouchDrag(_) => "touch_drag", PlatformInput::FileDrop(_) => "file_drop", PlatformInput::Touch(_) => "touch", } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 10bb1a5..5f24676 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5416,6 +5416,10 @@ impl Window { } PlatformInput::LongPress(long_press) } + PlatformInput::TouchDrag(touch_drag) => { + self.mouse_position = touch_drag.start_position; + PlatformInput::TouchDrag(touch_drag) + } PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, }; @@ -5508,6 +5512,11 @@ impl Window { event.predicted_position = None; } let recognized_gestures = self.touch_gestures.handle_event(&event); + if event.phase == crate::TouchPhase::Started + && let Some(touch_drag) = self.touch_gestures.offer_touch_drag(event.id) + { + self.dispatch_recognized_touch_gesture(touch_drag, cx); + } if event.phase == crate::TouchPhase::Started && self.touch_gestures.pending_long_press().is_some() { @@ -5550,6 +5559,17 @@ impl Window { cx.propagate_event = true; self.dispatch_mouse_event(&up, cx); } + RecognizedTouchGesture::TouchDrag(touch_drag) => { + self.mouse_position = touch_drag.start_position; + cx.propagate_event = true; + self.default_prevented = false; + let started = touch_drag.phase == crate::TouchPhase::Started; + self.dispatch_mouse_event(&touch_drag, cx); + if started { + self.touch_gestures + .resolve_touch_drag(self.default_prevented); + } + } RecognizedTouchGesture::LongPress(long_press) => { self.mouse_position = if long_press.phase == crate::TouchPhase::Started { long_press.start_position @@ -7345,8 +7365,8 @@ mod tests { ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, InputEvent as _, InteractiveElement as _, IntoElement, LongPressEvent, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions, - StatefulInteractiveElement as _, Styled, TestAppContext, TouchEvent, TouchId, TouchPhase, - Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, + StatefulInteractiveElement as _, Styled, TestAppContext, TouchDragEvent, TouchEvent, + TouchId, TouchPhase, Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, }; struct EmptyView; @@ -8049,6 +8069,53 @@ mod tests { assert_eq!(b_focus_count.get(), 1); } + #[gpui::test] + fn claimed_touch_drag_receives_movement_and_release(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let events = events.clone(); + move |_, _| TouchDragListener { events } + }); + let touch = TouchId(1); + + dispatch_touch(window, cx, touch, TouchPhase::Started, 10.); + dispatch_touch(window, cx, touch, TouchPhase::Moved, 30.); + dispatch_touch(window, cx, touch, TouchPhase::Ended, 40.); + + assert_eq!( + events.borrow().as_slice(), + [ + (TouchPhase::Started, px(10.)), + (TouchPhase::Moved, px(30.)), + (TouchPhase::Ended, px(40.)), + ] + ); + } + + struct TouchDragListener { + events: Rc>>, + } + + impl Render for TouchDragListener { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let events = self.events.clone(); + canvas( + |_, _, _| {}, + move |_, _, window, _| { + window.on_mouse_event(move |event: &TouchDragEvent, phase, window, _cx| { + if phase != DispatchPhase::Bubble { + return; + } + events.borrow_mut().push((event.phase, event.position.x)); + if event.phase == TouchPhase::Started { + window.prevent_default(); + } + }); + }, + ) + } + } + #[gpui::test] fn long_press_is_claimed_only_when_started_prevents_default(cx: &mut TestAppContext) { for response in [ @@ -8204,8 +8271,8 @@ mod tests { } } - fn dispatch_touch( - window: crate::WindowHandle, + fn dispatch_touch( + window: crate::WindowHandle, cx: &mut TestAppContext, id: TouchId, phase: TouchPhase, From 09f3e5c399e6af3192bb39d6b3904307d1d663cf Mon Sep 17 00:00:00 2001 From: Vlad Gaevsky Date: Wed, 2 Sep 2026 23:15:40 +0000 Subject: [PATCH 38/45] gpui: Snap recomputed padding to the device pixel grid before clamping scroll (#62296) # Objective Follow-up to #62135, taking the approach suggested there: fix the error at its source in GPUI instead of hiding it in the scrollbar. At fractional rem sizes (`py_1` at an odd UI font size), a container whose content fits becomes scrollable by a sub-pixel amount, and every uniform-list picker grows a full-height scrollbar thumb for that phantom range. ## Solution Taffy lays boxes out with every authored length snapped to the device pixel grid (`to_taffy`), and the final bounds are snapped again after layout. However, `Interactivity::clamp_scroll_position` recomputed the padding from the raw style with no snapping at all, so the padded content size exceeded the snapped bounds by up to half a device pixel, far past the existing two-decimal float-noise rounding, and `scroll_max` came out positive. The fix snaps the recomputed padding with the same `pixel_snap` helper the rest of the pipeline uses, so both operands of the subtraction sit on the same grid and the phantom range never comes into existence. Two neighbouring cases are deliberately left out to keep this scoped, both are follow-up candidates: percentage-based padding resolves inside Taffy without pre-snapping, so a discrepancy is still theoretically possible there; and `ListState::max_scroll_offset` (the older `list()` element) has no rounding protection at all. ## Testing - New regression test: a 50px container with `py(4.25)` padding and a child that fits exactly. It fails on main (`max_offset` comes out at 0.5px) and passes with the fix; verified in both directions. - Full gpui suite: 241 tests pass. - Reviewers can see the original symptom by opening any picker at a UI font size that makes `py_1` fractional in device pixels: a spurious full-height scrollbar thumb appears on main and is gone with this change. The math is logical-pixel only, so the behavior is platform-independent. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable (one pixel-snap per padding edge per clamp, negligible against existing per-frame layout work) cc @MrSubidubi Release Notes: - Fixed containers becoming scrollable by a sub-pixel amount, with a phantom full-height scrollbar thumb, at fractional UI font sizes. Co-authored-by: MrSubidubi --- crates/gpui/src/elements/div.rs | 51 ++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 173bd27..b98716e 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2346,7 +2346,14 @@ impl Interactivity { } let rem_size = window.rem_size(); - let padding = style.padding.to_pixels(bounds.size.into(), rem_size); + // Taffy lays the box out with the padding snapped to the device pixel + // grid (`to_taffy`); recomputed unsnapped, e.g. py_1 at a fractional + // rem size, it exceeds `bounds` and leaves the box scrollable by the + // sub-pixel difference. + let padding = style + .padding + .to_pixels(bounds.size.into(), rem_size) + .map(|edge| window.pixel_snap(*edge)); let padding_size = size(padding.left + padding.right, padding.top + padding.bottom); // The floating point values produced by Taffy and ours often vary // slightly after ~5 decimal places. This can lead to cases where after @@ -5165,6 +5172,48 @@ mod tests { assert_eq!(focused, Some(item_b.id)); } + #[gpui::test] + fn test_fractional_padding_does_not_make_a_fitting_container_scrollable( + cx: &mut TestAppContext, + ) { + struct PaddedContainer { + scroll_handle: ScrollHandle, + } + + impl Render for PaddedContainer { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + // 4.25px of padding snaps to 4px in layout, so a 42px child + // fits the 50px box exactly. + div().size_full().child( + div() + .id("container") + .h(px(50.)) + .w(px(100.)) + .py(px(4.25)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .child(div().w_full().h(px(42.))), + ) + } + } + + let scroll_handle = ScrollHandle::new(); + let window: AnyWindowHandle = cx + .add_window({ + let scroll_handle = scroll_handle.clone(); + move |_, _| PaddedContainer { scroll_handle } + }) + .into(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + + assert_eq!(scroll_handle.max_offset().y, px(0.)); + } + struct ContentSizedGrid; impl Render for ContentSizedGrid { From 5fe126d034f45af969f60b05e5848d3fe8f30ecf Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 3 Sep 2026 09:35:52 +0000 Subject: [PATCH 39/45] gpui: Add an option to keep hover listeners active after keyboard input (#63614) While working on https://github.com/zed-industries/zed/pull/63343, I ran into a problem with the pending key binding indicator. Hovering pauses its countdown, but pressing the next key made `on_hover` report false even though the mouse had not moved. The countdown then resumed while the pointer was still over the indicator. `on_hover` currently follows GPUI's normal hover rules. GPUI clears hover after keyboard input so the item under a stationary mouse does not interfere with keyboard navigation, for example list popover navigation with keyboard. This PR adds `HoverListenerMode` and `hover_listener_mode`. Callers that need to track the pointer across key presses can opt in like this: ```rs div() .hover_listener_mode(HoverListenerMode::InputModalityIndependent) .on_hover(|is_hovered, window, cx| { // ... }) ``` `InputModalityAware` remains the default. Release Notes: - N/A --- crates/gpui/src/elements/div.rs | 172 +++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index b98716e..b472808 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -649,6 +649,10 @@ impl Interactivity { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. /// Transitions caused by layout changes under a stationary mouse also invoke the callback. + /// + /// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set + /// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to + /// continue hit-testing hover after keyboard input. /// The imperative API equivalent to [`StatefulInteractiveElement::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. @@ -663,6 +667,16 @@ impl Interactivity { self.hover_listener = Some(Box::new(listener)); } + /// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary. + /// This affects only the hover listener, not hover styles or tooltips. The imperative API + /// equivalent to [`StatefulInteractiveElement::hover_listener_mode`]. + pub fn hover_listener_mode(&mut self, mode: HoverListenerMode) + where + Self: Sized, + { + self.hover_listener_mode = mode; + } + /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`]. pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) @@ -1593,6 +1607,10 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. /// Transitions caused by layout changes under a stationary mouse also invoke the callback. + /// + /// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set + /// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to + /// continue hit-testing hover after keyboard input. /// The fluent API equivalent to [`Interactivity::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. @@ -1604,6 +1622,17 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary. + /// This affects only the hover listener, not hover styles or tooltips. The fluent API + /// equivalent to [`Interactivity::hover_listener_mode`]. + fn hover_listener_mode(mut self, mode: HoverListenerMode) -> Self + where + Self: Sized, + { + self.interactivity().hover_listener_mode(mode); + self + } + /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The fluent API equivalent to [`Interactivity::tooltip`]. fn tooltip(mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self @@ -1658,6 +1687,29 @@ pub(crate) type PinchListener = pub(crate) type ClickListener = Rc; +/// Controls how [`StatefulInteractiveElement::on_hover`] responds to key presses while the mouse +/// is stationary. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum HoverListenerMode { + /// Use input-modality-aware hit testing. Keyboard input suppresses hover until the mouse moves + /// again, unless pointer capture or an active mouse-down interaction keeps the listener hovered. + #[default] + InputModalityAware, + /// Use hit testing even when the last input was from the keyboard. This changes only + /// keyboard-modality filtering; all other [`StatefulInteractiveElement::on_hover`] behavior + /// remains unchanged. + InputModalityIndependent, +} + +impl HoverListenerMode { + fn is_hovered(self, hitbox: &Hitbox, window: &Window) -> bool { + match self { + Self::InputModalityAware => hitbox.is_hovered(window), + Self::InputModalityIndependent => hitbox.id.is_hovered_ignoring_last_input(window), + } + } +} + pub(crate) struct DragListener { value: Arc, render: Box, &mut Window, &mut App) -> AnyView + 'static>, @@ -2079,6 +2131,7 @@ pub struct Interactivity { pub(crate) aux_click_listeners: Vec, pub(crate) drag_listener: Option, pub(crate) hover_listener: Option>, + pub(crate) hover_listener_mode: HoverListenerMode, pub(crate) tooltip_builder: Option, pub(crate) tooltip_show_delay: Option, pub(crate) window_control: Option, @@ -3034,9 +3087,11 @@ impl Interactivity { hover_listener(&is_hovered, window, cx); } }; + let hover_listener_mode = self.hover_listener_mode; if has_mouse_down.borrow().is_none() { - let is_hovered = !cx.has_active_drag() && hitbox.is_hovered(window); + let is_hovered = + !cx.has_active_drag() && hover_listener_mode.is_hovered(hitbox, window); if is_hovered != *was_hovered.borrow() { let update_hover = update_hover.clone(); window.defer(cx, move |window, cx| { @@ -3052,7 +3107,7 @@ impl Interactivity { if phase == DispatchPhase::Bubble { let is_hovered = has_mouse_down.borrow().is_none() && !cx.has_active_drag() - && hitbox.is_hovered(window); + && hover_listener_mode.is_hovered(&hitbox, window); update_hover(is_hovered, window, cx); } } @@ -4385,7 +4440,9 @@ mod tests { } #[gpui::test] - fn hover_listeners_update_when_layout_changes_under_stationary_mouse(cx: &mut TestAppContext) { + fn default_hover_listener_updates_when_layout_changes_under_stationary_mouse( + cx: &mut TestAppContext, + ) { let hover_transitions = Rc::new(RefCell::new(Vec::new())); let window = cx.add_window({ let hover_transitions = hover_transitions.clone(); @@ -4425,7 +4482,114 @@ mod tests { } #[gpui::test] - fn hover_listeners_remain_hovered_during_stationary_mouse_press(cx: &mut TestAppContext) { + fn default_hover_listener_ends_after_key_press(cx: &mut TestAppContext) { + let hover_transitions = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let hover_transitions = hover_transitions.clone(); + move |_, _| HoverListenerLayoutTestView { + target_left: px(0.), + hover_transitions, + } + }); + let any_window = AnyWindowHandle::from(window); + + cx.update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(point(px(10.), px(10.)), cx); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + key_down(cx, any_window, "a"); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false]); + } + + struct HoverListenerModeLayoutTestView { + target_left: Pixels, + hover_transitions: Rc>>, + } + + impl Render for HoverListenerModeLayoutTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let hover_transitions = self.hover_transitions.clone(); + div().relative().size_full().child( + div() + .id("hover-target") + .absolute() + .left(self.target_left) + .top_0() + .size(px(20.)) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .on_hover(move |is_hovered, _, _| { + hover_transitions.borrow_mut().push(*is_hovered); + }), + ) + } + } + + #[gpui::test] + fn input_modality_independent_hover_listener_updates_after_key_press(cx: &mut TestAppContext) { + let hover_transitions = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let hover_transitions = hover_transitions.clone(); + move |_, _| HoverListenerModeLayoutTestView { + target_left: px(40.), + hover_transitions, + } + }); + let any_window = AnyWindowHandle::from(window); + let pointer_position = point(px(10.), px(10.)); + + cx.update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(pointer_position, cx); + }) + .unwrap(); + assert!(hover_transitions.borrow().is_empty()); + + key_down(cx, any_window, "a"); + window + .update(cx, |view, _, cx| { + view.target_left = px(0.); + cx.notify(); + }) + .unwrap(); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + key_down(cx, any_window, "b"); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + cx.update_window(any_window, |_, window, cx| { + window.simulate_mouse_move(point(px(30.), px(10.)), cx); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false]); + + cx.update_window(any_window, |_, window, cx| { + window.simulate_mouse_move(pointer_position, cx); + window.dispatch_event( + MouseExitEvent { + position: pointer_position, + ..Default::default() + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false, true, false]); + } + + #[gpui::test] + fn default_hover_listener_remains_hovered_during_stationary_mouse_press( + cx: &mut TestAppContext, + ) { let hover_transitions = Rc::new(RefCell::new(Vec::new())); let window = cx.add_window({ let hover_transitions = hover_transitions.clone(); From e16ceac481b100cd4ffb0fa0b91465c93908ef7e Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 3 Sep 2026 09:35:53 +0000 Subject: [PATCH 40/45] which_key: Add a pending key binding indicator (#63343) This PR makes Zed's one-second key binding delay visible. Say both ctrl-w and ctrl-w left have bindings. After you press ctrl-w, Zed waits to see whether left follows. The PR shows that wait in the status bar with a countdown. Hovering the indicator shows which bindings can still match. It also adds a setting to hide the indicator. It does not change how Zed resolves key bindings. dyn-db84a840272e2f6250a3f527a59b1efd
dyn-8012278d030b41bc8ebfae1540504eff
file-5e6ffa90944faa294e7870f471b4e65b Release Notes: - Added a status bar countdown for pending multi-stroke key bindings. --------- Co-authored-by: Kirill Bulatov --- crates/gpui/src/key_dispatch.rs | 719 +++++++++++++++++++++++++------- crates/gpui/src/window.rs | 323 ++++++++++++-- 2 files changed, 841 insertions(+), 201 deletions(-) diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 5a037eb..f18bd0e 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -650,6 +650,248 @@ mod tests { ) } + struct PendingInputTestView { + focus_handle: FocusHandle, + action_count: Rc>, + secondary_action_count: Rc>, + } + + #[derive(Clone)] + struct PendingTextInputTestView { + focus_handle: FocusHandle, + text: Rc>, + action_count: Rc>, + } + + impl PendingTextInputTestView { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + action_count: Rc::default(), + } + } + } + + impl Element for PendingTextInputTestView { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some("pending-text-input-test".into()) + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + let action_count = self.action_count.clone(); + window.on_action( + std::any::TypeId::of::(), + move |_, phase, _, _| { + if phase == DispatchPhase::Bubble { + action_count.set(action_count.get() + 1); + } + }, + ); + } + } + + impl IntoElement for PendingTextInputTestView { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for PendingTextInputTestView { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { + true + } + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + + impl Render for PendingTextInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + struct PendingInputTimeoutPauseOwner; + + impl Render for PendingInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + use crate::{InteractiveElement as _, Styled as _}; + let action_count = self.action_count.clone(); + let secondary_action_count = self.secondary_action_count.clone(); + crate::div() + .key_context("Terminal") + .track_focus(&self.focus_handle) + .size_full() + .on_action(move |_: &TestAction, _, _| { + action_count.set(action_count.get() + 1); + }) + .on_action(move |_: &SecondaryTestAction, _, _| { + secondary_action_count.set(secondary_action_count.get() + 1); + }) + } + } + + fn setup_pending_input_test( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + cx.update(|cx| cx.bind_keys(bindings)); + + let action_count = Rc::new(Cell::new(0)); + let secondary_action_count = Rc::new(Cell::new(0)); + let (view, cx) = cx.add_window_view(|_, cx| PendingInputTestView { + focus_handle: cx.focus_handle(), + action_count: action_count.clone(), + secondary_action_count: secondary_action_count.clone(), + }); + let focus_handle = cx.update(|_, cx| view.read(cx).focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + (cx, action_count, secondary_action_count) + } + + fn setup_pending_input_timeout_test( + cx: &mut TestAppContext, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ) + } + + fn query_prefers_ime_for_printable_keys(cx: &mut VisualTestContext) -> Option { + let mut platform_window = cx.test_window(cx.window_handle()); + let mut input_handler = platform_window.take_input_handler()?; + let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); + platform_window.set_input_handler(input_handler); + Some(prefers_ime) + } + + fn simulate_pending_binding(cx: &mut VisualTestContext) { + cx.simulate_modifiers_change(crate::Modifiers::control()); + cx.simulate_keystrokes("ctrl-b"); + cx.simulate_modifiers_change(crate::Modifiers::default()); + } + #[test] fn test_keybinding_for_action_bounds() { let tree = test_dispatch_tree(vec![KeyBinding::new( @@ -1063,187 +1305,352 @@ mod tests { } #[crate::test] - fn test_input_handler_pending(cx: &mut TestAppContext) { - #[derive(Clone)] - struct CustomElement { - focus_handle: FocusHandle, - text: Rc>, - action_count: Rc>, - } - impl CustomElement { - fn new(cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - text: Rc::default(), - action_count: Rc::default(), - } - } - } - impl Element for CustomElement { - type RequestLayoutState = (); + fn test_printable_pending_input_replays_on_timeout(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("j k", TestAction, Some("Terminal"))]); + }); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); - type PrepaintState = (); + cx.simulate_keystrokes("j"); - fn id(&self) -> Option { - Some("custom".into()) - } - fn source_location(&self) -> Option<&'static panic::Location<'static>> { - None - } - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - (window.request_layout(Style::default(), [], cx), ()) - } - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - window.set_focus_handle(&self.focus_handle, cx); - } - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let mut key_context = KeyContext::default(); - key_context.add("Terminal"); - window.set_key_context(key_context); - window.handle_input(&self.focus_handle, self.clone(), cx); - let action_count = self.action_count.clone(); - window.on_action( - std::any::TypeId::of::(), - move |_, phase, _, _| { - if phase == DispatchPhase::Bubble { - action_count.set(action_count.get() + 1); - } - }, - ); - } - } - impl IntoElement for CustomElement { - type Element = Self; + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_some()); + }); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), ""); + }); - fn into_element(self) -> Self::Element { - self - } + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), "j"); + }); + } + + #[crate::test] + fn test_pending_input_timeout_dispatches_shorter_binding(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.update(|window, _| { + assert_eq!( + window + .pending_input() + .map(|pending_input| pending_input.keystrokes().len()), + Some(1) + ); + assert_eq!( + window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .map(|timeout| timeout.duration()), + Some(crate::PENDING_INPUT_TIMEOUT) + ); + }); + assert_eq!(action_count.get(), 0); + + // Emulate a countdown indicator re-rendering the window while waiting for the timeout. + for _ in 0..10 { + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 10); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); } - impl InputHandler for CustomElement { - fn selected_text_range( - &mut self, - _: bool, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } - fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { - None - } + #[crate::test] + fn test_running_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); - fn text_for_range( - &mut self, - _: Range, - _: &mut Option>, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.simulate_keystrokes("h"); + cx.run_until_parked(); - fn replace_text_in_range( - &mut self, - replacement_range: Option>, - text: &str, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(text) - } + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); - fn replace_and_mark_text_in_range( - &mut self, - replacement_range: Option>, - new_text: &str, - _: Option>, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(new_text) - } + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); - fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } - fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { - true - } + #[crate::test] + fn test_pending_input_timeout_starts_when_binding_becomes_ambiguous(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); - fn bounds_for_range( - &mut self, - _: Range, - _: &mut Window, - _: &mut App, - ) -> Option> { - None - } + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_none()); + }); - fn character_index_for_point( - &mut self, - _: Point, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - } - impl Render for CustomElement { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - self.clone() - } - } + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_invalid_continuation_while_timeout_paused_replays_pending_input( + cx: &mut TestAppContext, + ) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("x", SecondaryTestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + cx.simulate_keystrokes("x"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_pending_input_timeout_pauses_and_resumes(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + let other_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&other_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resumes_when_owner_is_released(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 2); + cx.run_until_parked(); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT / 2); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_clearing_pending_input_invalidates_timeout_pause(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + window.focus(&cx.focus_handle(), cx); + assert!(!window.has_pending_keystrokes()); + }); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_input_handler_pending(cx: &mut TestAppContext) { cx.update(|cx| { cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); cx.bind_keys([KeyBinding::new("ctrl-x k", TestAction, Some("Terminal"))]); }); - let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); cx.update(|window, cx| { window.focus(&focus_handle, cx); window.activate_window(); }); - let query_prefers_ime_for_printable_keys = |cx: &mut VisualTestContext| { - let mut platform_window = cx.test_window(cx.window_handle()); - let mut input_handler = platform_window.take_input_handler()?; - let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); - platform_window.set_input_handler(input_handler); - Some(prefers_ime) - }; - assert_eq!(query_prefers_ime_for_printable_keys(cx), Some(true)); cx.simulate_keystrokes("ctrl-x"); cx.update(|window, _| assert!(window.has_pending_keystrokes())); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 5f24676..2c568c5 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1288,12 +1288,139 @@ pub(crate) enum DrawPhase { Focus, } +pub(crate) const PENDING_INPUT_TIMEOUT: Duration = Duration::from_secs(1); + +/// Pending input for a potential multi-stroke key binding. +pub struct PendingInputStatus<'a> { + keystrokes: &'a [Keystroke], + timeout: Option, +} + +impl<'a> PendingInputStatus<'a> { + /// Returns the keystrokes entered so far. + pub fn keystrokes(&self) -> &'a [Keystroke] { + self.keystrokes + } + + /// Returns the timeout state for flushing this input, if it needs a timeout. + pub fn timeout(&self) -> Option { + self.timeout + } +} + +/// The timeout state for pending input. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PendingInputTimeoutStatus { + duration: Duration, + remaining: Duration, + started_at: Option, + paused: bool, +} + +impl PendingInputTimeoutStatus { + /// Returns the full timeout duration. + pub fn duration(&self) -> Duration { + self.duration + } + + /// Returns the duration remaining before pending input is flushed. + pub fn remaining(&self, cx: &App) -> Duration { + self.started_at + .map(|started_at| { + self.remaining + .saturating_sub(cx.background_executor().now() - started_at) + }) + .unwrap_or(self.remaining) + } + + /// Returns whether the timeout is paused. + pub fn is_paused(&self) -> bool { + self.paused + } +} + +#[derive(Debug)] +struct PendingInputTimeout { + duration: Duration, + remaining: Duration, + state: PendingInputTimeoutState, +} + +#[derive(Debug)] +enum PendingInputTimeoutState { + Running { started_at: Instant, task: Task<()> }, + Paused { pause: PendingInputTimeoutPause }, +} + +#[derive(Debug)] +struct PendingInputTimeoutPause { + owner_id: EntityId, + _release_subscription: Subscription, +} + +impl PendingInputTimeout { + fn is_paused(&self) -> bool { + matches!(&self.state, PendingInputTimeoutState::Paused { .. }) + } + + fn pause(&mut self, pause: PendingInputTimeoutPause, now: Instant) -> bool { + match std::mem::replace(&mut self.state, PendingInputTimeoutState::Paused { pause }) { + PendingInputTimeoutState::Running { started_at, task } => { + self.remaining = self.remaining.saturating_sub(now - started_at); + drop(task); + true + } + previous_state @ PendingInputTimeoutState::Paused { .. } => { + self.state = previous_state; + false + } + } + } + + fn pause_owner_id(&self) -> Option { + match &self.state { + PendingInputTimeoutState::Running { .. } => None, + PendingInputTimeoutState::Paused { pause } => Some(pause.owner_id), + } + } + + fn resume(&mut self, owner_id: EntityId, started_at: Instant, task: Task<()>) -> bool { + match std::mem::replace( + &mut self.state, + PendingInputTimeoutState::Running { started_at, task }, + ) { + PendingInputTimeoutState::Paused { pause } if pause.owner_id == owner_id => true, + previous_state => { + self.state = previous_state; + false + } + } + } + + fn reset_duration(&mut self, duration: Duration) { + self.duration = duration; + self.remaining = duration; + } + + fn status(&self) -> PendingInputTimeoutStatus { + let (started_at, paused) = match &self.state { + PendingInputTimeoutState::Running { started_at, .. } => (Some(*started_at), false), + PendingInputTimeoutState::Paused { .. } => (None, true), + }; + PendingInputTimeoutStatus { + duration: self.duration, + remaining: self.remaining, + started_at, + paused, + } + } +} + #[derive(Default, Debug)] struct PendingInput { keystrokes: SmallVec<[Keystroke; 1]>, focus: Option, - timer: Option>, - needs_timeout: bool, + timeout: Option, } pub(crate) struct ElementStateBox { @@ -5766,7 +5893,7 @@ impl Window { } if !match_result.pending.is_empty() { - currently_pending.timer.take(); + let previous_timeout = currently_pending.timeout.take(); currently_pending.keystrokes = match_result.pending; currently_pending.focus = self.focus; @@ -5780,38 +5907,23 @@ impl Window { accepts }); - currently_pending.needs_timeout |= - match_result.pending_has_binding || text_input_requires_timeout; - - if currently_pending.needs_timeout { - currently_pending.timer = Some(self.spawn(cx, async move |cx| { - cx.background_executor.timer(Duration::from_secs(1)).await; - cx.update(move |window, cx| { - let Some(currently_pending) = window - .pending_input - .take() - .filter(|pending| pending.focus == window.focus) - else { - return; - }; - - let node_id = window.focus_node_id_in_rendered_frame(window.focus); - let dispatch_path = - window.rendered_frame.dispatch_tree.dispatch_path(node_id); - - let to_replay = window - .rendered_frame - .dispatch_tree - .flush_dispatch(currently_pending.keystrokes, &dispatch_path); - - window.pending_input_changed(cx); - window.replay_pending_input(to_replay, cx) - }) - .log_err(); - })); + let needs_timeout = previous_timeout.is_some() + || match_result.pending_has_binding + || text_input_requires_timeout; + currently_pending.timeout = if needs_timeout { + match previous_timeout { + Some(mut timeout) if timeout.is_paused() => { + timeout.reset_duration(PENDING_INPUT_TIMEOUT); + Some(timeout) + } + previous_timeout => { + drop(previous_timeout); + Some(self.new_pending_input_timeout(PENDING_INPUT_TIMEOUT, cx)) + } + } } else { - currently_pending.timer = None; - } + None + }; self.pending_input = Some(currently_pending); self.pending_input_changed(cx); cx.propagate_event = false; @@ -5854,6 +5966,44 @@ impl Window { self.pending_input_changed(cx); } + fn new_pending_input_timeout(&self, duration: Duration, cx: &App) -> PendingInputTimeout { + let (started_at, task) = self.start_pending_input_timeout(duration, cx); + PendingInputTimeout { + duration, + remaining: duration, + state: PendingInputTimeoutState::Running { started_at, task }, + } + } + + fn start_pending_input_timeout(&self, remaining: Duration, cx: &App) -> (Instant, Task<()>) { + let started_at = cx.background_executor().now(); + let task = self.spawn(cx, async move |cx| { + cx.background_executor.timer(remaining).await; + cx.update(move |window, cx| { + let Some(currently_pending) = window + .pending_input + .take() + .filter(|pending| pending.focus == window.focus) + else { + return; + }; + + let node_id = window.focus_node_id_in_rendered_frame(window.focus); + let dispatch_path = window.rendered_frame.dispatch_tree.dispatch_path(node_id); + + let to_replay = window + .rendered_frame + .dispatch_tree + .flush_dispatch(currently_pending.keystrokes, &dispatch_path); + + window.pending_input_changed(cx); + window.replay_pending_input(to_replay, cx) + }) + .log_err(); + }); + (started_at, task) + } + fn finish_dispatch_key_event( &mut self, event: &dyn Any, @@ -5944,17 +6094,9 @@ impl Window { } } - /// Pending input that can still complete a binding. Input left over from a previous focus can - /// never complete one. - fn active_pending_input(&self) -> Option<&PendingInput> { - self.pending_input - .as_ref() - .filter(|pending_input| pending_input.focus == self.focus) - } - /// Determine whether a potential multi-stroke key binding is in progress on this window. pub fn has_pending_keystrokes(&self) -> bool { - self.active_pending_input().is_some() + self.pending_input().is_some() } #[cfg(test)] @@ -5968,10 +6110,101 @@ impl Window { } } + /// Returns pending input that can still complete a multi-stroke key binding. Input left over + /// from a previous focus can never complete one. + pub fn pending_input(&self) -> Option> { + self.pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .map(|pending_input| PendingInputStatus { + keystrokes: pending_input.keystrokes.as_slice(), + timeout: pending_input + .timeout + .as_ref() + .map(PendingInputTimeout::status), + }) + } + + /// Pauses or resumes the current pending input timeout on behalf of `owner`. + /// + /// A paused timeout resumes automatically if `owner` is released. Returns whether the timeout + /// state changed. A timeout paused by one owner cannot be resumed by another. + pub fn set_pending_input_timeout_paused( + &mut self, + owner: &Entity, + paused: bool, + cx: &mut App, + ) -> bool { + let owner_id = owner.entity_id(); + if !paused { + return self.resume_pending_input_timeout(owner_id, cx); + } + + let timeout = self + .pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_ref()); + let Some(timeout) = timeout else { + return false; + }; + if timeout.is_paused() { + return false; + } + + let release_subscription = self.observe_release(owner, cx, move |_, window, cx| { + window.resume_pending_input_timeout(owner_id, cx); + }); + let now = cx.background_executor().now(); + let changed = self + .pending_input + .as_mut() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| { + timeout.pause( + PendingInputTimeoutPause { + owner_id, + _release_subscription: release_subscription, + }, + now, + ) + }); + + if changed { + self.defer_pending_input_changed(cx); + } + changed + } + + fn resume_pending_input_timeout(&mut self, owner_id: EntityId, cx: &mut App) -> bool { + let Some(remaining) = self + .pending_input + .as_ref() + .and_then(|pending_input| pending_input.timeout.as_ref()) + .filter(|timeout| timeout.pause_owner_id() == Some(owner_id)) + .map(|timeout| timeout.remaining) + else { + return false; + }; + + let (started_at, task) = self.start_pending_input_timeout(remaining, cx); + let changed = self + .pending_input + .as_mut() + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| timeout.resume(owner_id, started_at, task)); + + if changed { + self.defer_pending_input_changed(cx); + } + changed + } + /// Returns the currently pending input keystrokes that might result in a multi-stroke key binding. pub fn pending_input_keystrokes(&self) -> Option<&[Keystroke]> { - self.active_pending_input() - .map(|pending_input| pending_input.keystrokes.as_slice()) + self.pending_input() + .map(|pending_input| pending_input.keystrokes()) } fn replay_pending_input(&mut self, replays: SmallVec<[Replay; 1]>, cx: &mut App) { From c088d4eaf6b0f743d264a81fd18bffbbbcfd9f3c Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 3 Sep 2026 14:36:51 +0000 Subject: [PATCH 41/45] gpui: Add `Hitbox::is_hovered_at` (#63696) Adds `Hitbox::is_hovered_at` to improve touch selection on delta web --- crates/gpui/src/window.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2c568c5..8454572 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -862,6 +862,17 @@ impl Hitbox { self.id.is_hovered(window) } + /// Checks whether this hitbox would be hovered at `position`, regardless of the current input + /// modality or mouse position. + pub fn is_hovered_at(&self, position: Point, window: &Window) -> bool { + let hit_test = window.rendered_frame.hit_test(position); + hit_test + .ids + .iter() + .take(hit_test.hover_hitbox_count) + .any(|id| self.id == *id) + } + /// Checks if the hitbox contains the mouse and should handle scroll events. Typically this /// should only be used when handling `ScrollWheelEvent`, and otherwise `is_hovered` should be /// used. See the documentation of `Hitbox::is_hovered` for details about this distinction. From 04f85353fe7e6f64f375b1c84cb0adf5b500819d Mon Sep 17 00:00:00 2001 From: Coinleft LFT <98520092+coinleftt@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:02:13 +0000 Subject: [PATCH 42/45] Redact environment secrets from dev container `docker exec` logs (#63606) # Objective Fixes #63569 `run_docker_command` Debug-formatted the whole `docker exec` command, so every `-e NAME=VALUE` pair forwarded into a dev container was written to `Zed.log` in plaintext: once at DEBUG, and again in the error message that `remote_client.rs` logs at ERROR on a failed reconnect. ## Solution Render the command with `redact_arguments`, which replaces the value of every `-e NAME=VALUE` argument with `` before the arguments are flattened, and pass the remaining arguments and stderr through `util::redact::redact_command`. Validate `containerEnv` and `remoteEnv` keys in `spawn_dev_container` before any container is reused or built, because an empty key produced `-e =VALUE`, which the Docker CLI rejects while echoing the value verbatim to stderr. Skip invalid names in `remote` as well, since `remote_env` is also rehydrated from the workspace database. The shared predicate lives in `util::redact::is_valid_environment_name`. The DEBUG line now logs the redacted command and exit status only; the previous stdout and stderr byte dumps are gone. ## Testing `cargo test -p remote -p dev_container --lib`. Unit tests cover redaction of forwarded env, argument boundaries, assignments inside program arguments and stderr, invalid-name skipping and rejection, and the podman CLI. Release Notes: - Fixed dev container environment variables being logged in full --------- Co-authored-by: Kirill Bulatov --- crates/util/src/redact.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/util/src/redact.rs b/crates/util/src/redact.rs index ad11f76..a9a82bf 100644 --- a/crates/util/src/redact.rs +++ b/crates/util/src/redact.rs @@ -20,6 +20,13 @@ pub fn should_redact(env_var_name: &str) -> bool { .any(|suffix| env_var_name.ends_with(suffix)) } +pub fn is_valid_environment_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|character| character != '=' && !character.is_control()) +} + /// Redact a string which could include a command with environment variables pub fn redact_command(command: &str) -> String { REDACT_REGEX From e4f8573c594c29d2139228ed782bc22843404ad0 Mon Sep 17 00:00:00 2001 From: shxmbles <55964414+shxmbles@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:19:04 +0000 Subject: [PATCH 43/45] Add `disable_ai` check for "Agent Panel" `MenuItem` (#63580) # Objective - Removes Agent Panel menu item when AI disabled toggle is on. - Fixes #63497 ## Solution - Use the global DisableAiSettings - disable_ai check in `app_menus.rs`. To keep the order the same I 1. Check the disable AI setting 2. If true push to the `view_items` vec![] 3. Extend the vec![] with the remaining Menu Items and Separators. ## Testing This can be tested visually: 1. Open Zed with AI settings enabled 2. Click View when Zed is focused 3. Noting where Agent Panel is on the list and to make sure clicking it opens up the Agent Panel. 4. Open settings 5. Navigate to AI settings 6. Toggle disable AI on 7. Click View Expected: Agent Panel is gone from the menu. The opposite is also expected. - This was tested on macOS 26.6.2 (25G83). ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [] Tests cover the new/changed behavior To keep this PR simple, I did not add any testing to this, as there were no tests in this file already. - [x] Performance impact has been considered and is acceptable ## Showcase
Click to view showcase https://github.com/user-attachments/assets/50db44ac-6c11-4101-a064-fd4e3b285620
--- crates/gpui/src/platform/test/platform.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 5e82fbe..df1d0f4 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -4,7 +4,7 @@ use crate::NoopTextSystem; use crate::PathPromptOptions; use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, Platform, PlatformDisplay, + DummyKeyboardMapper, ForegroundExecutor, Keymap, OwnedMenu, Platform, PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task, TestDisplay, TestWindow, @@ -45,6 +45,7 @@ pub(crate) struct TestPlatform { RefCell, Vec)>>>, headless_renderer_factory: Option Option>>>, weak: Weak, + menus: RefCell>, } #[derive(Clone)] @@ -159,6 +160,7 @@ impl TestPlatform { system_notifications: Default::default(), text_system, headless_renderer_factory, + menus: Default::default(), }) } @@ -573,7 +575,14 @@ impl Platform for TestPlatform { self.system_notifications.borrow_mut().response_callback = Some(callback); } - fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + fn set_menus(&self, menus: Vec, _keymap: &Keymap) { + *self.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect() + } + + fn get_menus(&self) -> Option> { + Some(self.menus.borrow().clone()) + } + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} fn add_recent_document(&self, _paths: &Path) {} From 72534baa6a2abb02dd195e0256c85e10d453c1c7 Mon Sep 17 00:00:00 2001 From: Romain Ollier <35602101+RomainDW@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:12:55 +0000 Subject: [PATCH 44/45] macos: Honor "Tiled windows have margins" for the Fill titlebar action (#63759) # Objective Fixes #52884 On macOS, with **"Double-click a window's title bar"** set to **Fill** and **"Tiled windows have margins"** enabled (System Settings > Desktop & Dock), double-clicking Zed's title bar fills the whole visible frame with no margins, while native apps leave the margin in place. The `"Fill"` arm of `titlebar_double_click` currently falls back to `zoom:`, with a comment stating that there is no documented API for the Fill action. `zoom:` is the classic maximize: it targets the screen's visible frame and never goes through the system tiling path, so the margin preference cannot apply. ## Solution AppKit does implement the action: `_zoomFill:` is the selector behind **Window > Move & Resize > Fill**, and it goes through the system tiling path. Prefer it for the `"Fill"` case, keeping `zoom:` as a fallback when the window does not respond to it. This means the margin geometry stays owned by the OS: neither `EnableTiledWindowMargins` nor `TiledWindowSpacing` has to be read or reimplemented in gpui, and the behavior follows the setting when the user toggles it. The selector is underscore-prefixed, hence the `respondsToSelector:` guard and the `zoom:` fallback. ## Testing Measured on macOS 26.6.2 (25G76), aarch64, on a 2560x1440 display with the Dock on the left, so `visibleFrame` is `(80, 0, 2480, 1410)`. Probe: a plain `NSWindow` (`titled`, `resizable`) in a standalone AppKit binary, with `EnableTiledWindowMargins = 1` and `TiledWindowSpacing` unset (default 8). | action | resulting frame | insets vs `visibleFrame` | | --- | --- | --- | | `zoom:` | `(80, 0, 2480, 1410)` | 0 on all four sides | | `_zoomFill:` | `(88, 8, 2464, 1394)` | 8 pt on all four sides | `respondsToSelector: _zoomFill:` returns `YES` on that build. `cargo check -p gpui_macos` and `cargo fmt -p gpui_macos -- --check` both pass. To reproduce as a reviewer: enable both settings above, then double-click the title bar. Before this change the window touches the screen edges, after it the margin matches Finder or Safari. With "Tiled windows have margins" off, both behave the same. One behavior difference worth flagging: `zoom:` toggles, so a second double-click used to restore the previous frame, while `_zoomFill:` stays filled. AppKit exposes `_zoomUntile:` for the reverse direction, which restores the pre-fill frame in my testing. I left it out to keep the diff minimal, but I am happy to add the toggle if you would prefer that a second double-click untiles. I could not find a way to cover this with an automated test, since the assertion would be about AppKit's own tiling geometry. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Cargo.lock | 53 ++++++++++----------------------- crates/gpui/src/window.rs | 22 +++++++------- crates/gpui_macos/src/window.rs | 11 +++++-- 3 files changed, 37 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f44b4aa..4d8d566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -689,9 +689,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags 2.13.1", "cexpr", @@ -2186,16 +2186,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.34" @@ -2582,6 +2572,8 @@ dependencies = [ "util_macros", "uuid", "waker-fn", + "wasm-bindgen", + "web-sys", "web-time", "windows 0.61.3", "zed-font-kit", @@ -2790,7 +2782,7 @@ dependencies = [ "uuid", "windows 0.61.3", "windows-core 0.61.2", - "windows-numerics 0.2.0", + "windows-numerics 0.3.1", "windows-registry", "zed-scap", ] @@ -3643,12 +3635,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -4677,9 +4663,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.15.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -6116,13 +6102,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ - "futf", - "mac", - "utf-8", + "new_debug_unreachable", ] [[package]] @@ -6621,12 +6605,6 @@ dependencies = [ "xmlwriter", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -6896,6 +6874,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", + "log", "rustix 1.1.4", "scoped-tls", "smallvec", @@ -7437,13 +7416,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 8454572..6eadf9e 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -19,11 +19,11 @@ use crate::{ 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, - TextInputConfiguration, TextInputStateChange, - TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, - Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, - prelude::*, px, rems, size, transparent_black, + TextInputConfiguration, TextInputStateChange, TextRenderingMode, TextStyle, + TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + transparent_black, }; /// A gaussian is cut off after three standard deviations. @@ -4444,11 +4444,13 @@ impl Window { let element_bounds = bounds.scale(scale_factor); let transform_origin = match grows_from { - Some(origin) => element_bounds.origin - + point( - element_bounds.size.width * origin.x, - element_bounds.size.height * origin.y, - ), + Some(origin) => { + element_bounds.origin + + point( + element_bounds.size.width * origin.x, + element_bounds.size.height * origin.y, + ) + } None => element_bounds.center(), }; let source_bounds = element_bounds.dilate(ScaledPixels(filter.blur * BLUR_REACH)); diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 88a694c..d8f5b3c 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1978,9 +1978,16 @@ impl PlatformWindow for MacWindow { } } "Fill" => { - // There is no documented API for "Fill" action, so we'll just zoom the window if is_resizable { - window.zoom_(nil); + // Unlike `zoom:`, AppKit's private Fill action honors the system's + // "Tiled windows have margins" setting. + let responds_to_zoom_fill: BOOL = + msg_send![window, respondsToSelector: sel!(_zoomFill:)]; + if responds_to_zoom_fill == YES { + let _: () = msg_send![window, _zoomFill: nil]; + } else { + window.zoom_(nil); + } } } _ => { From 2cf54201fbd8a9eb105d6890168373d6dc09cdea Mon Sep 17 00:00:00 2001 From: vrdons Date: Mon, 7 Sep 2026 18:01:16 +0300 Subject: [PATCH 45/45] deps: Update to latest version git: 22 packages incompatible: 93 packages latest: 201 packages local: 23 packages pinned: 4 packages --- Cargo.lock | 475 ++++++++++++++++++++++------------- Cargo.toml | 217 ++++++++-------- crates/gpui/Cargo.toml | 8 +- crates/gpui_linux/Cargo.toml | 20 +- crates/util/Cargo.toml | 2 +- crates/ztracing/Cargo.toml | 4 +- 6 files changed, 420 insertions(+), 306 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d8d566..cb569c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,9 +363,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" dependencies = [ "compression-codecs", "compression-core", @@ -456,7 +456,7 @@ dependencies = [ [[package]] name = "async-process" version = "2.5.0" -source = "git+https://github.com/zed-industries/async-process.git?rev=0b6d6713570af61806e1e5cb40e0f757cb93fd9d#0b6d6713570af61806e1e5cb40e0f757cb93fd9d" +source = "git+https://github.com/smol-rs/async-process.git?rev=f4485f156f9294b86a5be37f7236bcf0cf93c76b#f4485f156f9294b86a5be37f7236bcf0cf93c76b" dependencies = [ "async-channel 2.5.0", "async-io", @@ -552,7 +552,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -675,7 +675,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", @@ -709,33 +709,37 @@ dependencies = [ [[package]] name = "bit-set" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec 0.8.0", + "bit-vec 0.9.1", ] [[package]] name = "bit-set" -version = "0.9.1" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" dependencies = [ - "bit-vec 0.9.1", + "bit-vec 0.10.1", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bit-vec" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] [[package]] name = "bit_field" @@ -817,9 +821,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel 2.5.0", "async-task", @@ -830,14 +834,28 @@ dependencies = [ [[package]] name = "borsh" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ + "borsh-derive", "bytes", "cfg_aliases", ] +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "bstr" version = "1.13.1" @@ -877,7 +895,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -909,8 +927,9 @@ dependencies = [ [[package]] name = "calloop" -version = "0.14.3" -source = "git+https://github.com/zed-industries/calloop#eb6b4fd17b9af5ecc226546bdd04185391b3e265" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.13.1", "polling", @@ -966,9 +985,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -1118,7 +1137,7 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ "bitflags 2.13.1", "block", - "cocoa-foundation 0.2.0", + "cocoa-foundation 0.2.1", "core-foundation 0.10.1", "core-graphics 0.24.0", "foreign-types", @@ -1142,15 +1161,14 @@ dependencies = [ [[package]] name = "cocoa-foundation" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ "bitflags 2.13.1", "block", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "libc", "objc", ] @@ -1198,9 +1216,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" dependencies = [ "bzip2", "compression-core", @@ -1211,9 +1229,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "concurrent-queue" @@ -1394,6 +1412,12 @@ dependencies = [ "metal", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -1438,18 +1462,18 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1492,18 +1516,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1511,27 +1535,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1724,7 +1748,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -1777,9 +1801,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "embed-resource" @@ -1790,18 +1814,24 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "vswhom", "winreg", ] [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1966,7 +1996,7 @@ dependencies = [ "bit_field", "half", "lebe", - "miniz_oxide", + "miniz_oxide 0.8.9", "num-complex", "pulp", "rayon-core", @@ -2030,9 +2060,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -2042,12 +2072,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2109,9 +2140,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" dependencies = [ "bytemuck", ] @@ -2157,7 +2188,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -2283,7 +2314,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -2385,6 +2416,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -2891,9 +2923,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -3081,9 +3113,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3156,15 +3188,15 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imgref" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -3368,9 +3400,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -3482,9 +3514,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -3542,9 +3574,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "serde_core", "value-bag", @@ -3585,9 +3617,9 @@ dependencies = [ [[package]] name = "lyon_algorithms" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" dependencies = [ "lyon_path", "num-traits", @@ -3626,9 +3658,9 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005" dependencies = [ "float_next_after", "lyon_path", @@ -3780,6 +3812,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3790,6 +3832,34 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multiversion" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "29.0.4" @@ -4345,9 +4415,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" dependencies = [ "is-wsl", "libc", @@ -4361,9 +4431,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" -version = "5.3.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ "num-traits", ] @@ -4610,7 +4680,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4623,7 +4693,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4663,15 +4733,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -4782,16 +4852,17 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" -source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +version = "1.11.0" +source = "git+https://github.com/proptest-rs/proptest?rev=a6f033cf83adfd55557b86e6065e6f4df054ec70#a6f033cf83adfd55557b86e6065e6f4df054ec70" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.1", + "core_detect", "num-traits", "proptest-macro", - "rand 0.9.5", - "rand_chacha 0.9.0", + "rand 0.10.2", + "rand_chacha 0.10.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4802,12 +4873,12 @@ dependencies = [ [[package]] name = "proptest-macro" version = "0.5.0" -source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +source = "git+https://github.com/proptest-rs/proptest?rev=a6f033cf83adfd55557b86e6065e6f4df054ec70#a6f033cf83adfd55557b86e6065e6f4df054ec70" dependencies = [ "convert_case 0.11.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -4902,9 +4973,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4921,6 +4992,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4941,6 +5022,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -4959,13 +5050,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5095,7 +5192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.3", + "font-types 0.12.4", "once_cell", ] @@ -5136,22 +5233,22 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5415,7 +5512,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5512,7 +5609,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5523,7 +5620,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5570,7 +5667,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5627,7 +5724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -5677,6 +5774,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -5729,9 +5832,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "smol" @@ -5824,7 +5927,7 @@ checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5886,15 +5989,15 @@ dependencies = [ [[package]] name = "sval" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" +checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" [[package]] name = "sval_buffer" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" +checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" dependencies = [ "sval", "sval_ref", @@ -5903,18 +6006,18 @@ dependencies = [ [[package]] name = "sval_dynamic" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" +checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" +checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" dependencies = [ "itoa", "ryu", @@ -5923,9 +6026,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" +checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" dependencies = [ "itoa", "ryu", @@ -5934,9 +6037,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" +checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" dependencies = [ "sval", "sval_buffer", @@ -5945,18 +6048,18 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" +checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" +checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" dependencies = [ "serde_core", "sval", @@ -6003,9 +6106,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -6076,6 +6179,12 @@ dependencies = [ "objc", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tauri-winrt-notification" version = "0.7.3" @@ -6155,7 +6264,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -6257,9 +6366,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -6284,9 +6393,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap", "serde_core", @@ -6437,9 +6546,9 @@ dependencies = [ [[package]] name = "tracy-client" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f6fc3baeac5d86ab90c772e9e30620fc653bf1864295029921a15ef478e6a5" +checksum = "6131992ff3e2cb96f407eb4eb005427ba15e2687260d99089b90e60e33169ce0" dependencies = [ "loom", "once_cell", @@ -6448,9 +6557,9 @@ dependencies = [ [[package]] name = "tracy-client-sys" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f7c95348f20c1c913d72157b3c6dee6ea3e30b3d19502c5a7f6d3f160dacbf" +checksum = "ab27f167b093214c68413a2e0bcd318f0591af475597405ff68138ba0a433379" dependencies = [ "cc", "windows-targets", @@ -6670,9 +6779,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.24.1" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6700,9 +6809,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" dependencies = [ "value-bag-serde1", "value-bag-sval2", @@ -6710,9 +6819,9 @@ dependencies = [ [[package]] name = "value-bag-serde1" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82" +checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" dependencies = [ "erased-serde", "serde_core", @@ -6721,9 +6830,9 @@ dependencies = [ [[package]] name = "value-bag-sval2" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e" +checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" dependencies = [ "sval", "sval_buffer", @@ -6802,9 +6911,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -6815,9 +6924,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -6825,9 +6934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6835,22 +6944,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -6967,9 +7076,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -7868,7 +7977,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", "zbus_names", "zvariant", "zvariant_utils", @@ -7940,7 +8049,7 @@ dependencies = [ "core-graphics-helmer-fork", "log", "objc", - "rand 0.8.7", + "rand 0.8.8", "screencapturekit", "screencapturekit-sys", "sysinfo", @@ -8044,9 +8153,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -8055,15 +8164,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zlog" version = "0.1.0" @@ -8127,9 +8242,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", @@ -8143,26 +8258,26 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "4.0.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.3", + "syn 3.0.5", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index f81c730..3b2efe9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ edition = "2024" [workspace.dependencies] collections = { path = "crates/collections", version = "0.1.0" } console = "0.16" -crossbeam = "0.8.4" +crossbeam = "0.8.5" derive_refineable = { path = "crates/refineable/derive_refineable" } dialoguer = { version = "0.12", default-features = false } gpui = { path = "crates/gpui", default-features = false } @@ -53,7 +53,7 @@ mime = "0.3.17" path = { path = "crates/path" } perf = { path = "tooling/perf" } refineable = { path = "crates/refineable" } -rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] } +rodio = { git = "https://github.com/RustAudio/rodio", rev = "685e05765c2aff8fbb51e32cda5b18428d09ffbe", features = ["wav", "playback", "wav_output", "recording"] } scheduler = { path = "crates/scheduler" } sum_tree = { path = "crates/sum_tree" } util = { path = "crates/util" } @@ -66,15 +66,15 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -accesskit = { version = "0.24.0", features = ["enumn"] } -accesskit_macos = "0.26.0" -accesskit_unix = "0.21.0" +accesskit = { version = "0.24.1", features = ["enumn"] } +accesskit_macos = "0.26.3" +accesskit_unix = "0.21.1" accesskit_windows = "0.33.1" agent-client-protocol = { version = "=2.0.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" -anyhow = "1.0.86" +anyhow = "1.0.104" ashpd = { version = "0.13", default-features = false, features = [ "async-io", "notification", @@ -84,66 +84,66 @@ ashpd = { version = "0.13", default-features = false, features = [ "trash" ] } async-channel = "2.5.0" -async-compat = "0.2.1" +async-compat = "0.2.5" async-compression = { version = "0.4", features = ["bzip2", "gzip", "futures-io"] } async-dispatcher = "0.1" -async-fs = "2.1" +async-fs = "2.2" async-io = "2.6.0" async-lock = "3.4.2" async-pipe = { git = "https://github.com/zed-industries/async-pipe-rs", rev = "82d00a04211cf4e1236029aa03e6b6ce2a74c553" } -async-recursion = "1.0.0" +async-recursion = "1.1.1" # `unstable` is required to compile async-tar's Windows symlink support. -async-std = { version = "1.12", features = ["unstable"] } +async-std = { version = "1.13", features = ["unstable"] } async-tar = "0.6" async-task = "4.7" async-trait = "0.1" async-tungstenite = "0.31.0" async-process = "2.5.0" async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] } -aws-config = { version = "1.8.10", features = ["behavior-version-latest"] } -aws-credential-types = { version = "1.2.8", features = [ +aws-config = { version = "1.12.0", features = ["behavior-version-latest"] } +aws-credential-types = { version = "1.3.0", features = [ "hardcoded-credentials", ] } -aws-sdk-bedrockruntime = { version = "1.112.0", features = [ +aws-sdk-bedrockruntime = { version = "1.143.0", features = [ "behavior-version-latest", ] } -aws-sigv4 = { version = "1.4.0", features = ["http1"] } -aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } -aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } +aws-sigv4 = { version = "1.5.1", features = ["http1"] } +aws-smithy-runtime-api = { version = "1.16.0", features = ["http-1x", "client"] } +aws-smithy-types = { version = "1.6.3", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" -bitflags = "2.6.0" +bitflags = "2.13.1" block = "0.1" block2 = "0.6" -brotli = "8.0.2" -bytes = "1.0" +brotli = "8.0.4" +bytes = "1.12" cargo_metadata = "0.19" cargo_toml = "0.21" brush-parser = "0.3" cbindgen = { version = "0.28.0", default-features = false } -cfg-if = "1.0.3" +cfg-if = "1.0.4" chardetng = "0.1" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" -circular-buffer = "1.0" -clap = { version = "4.4", features = ["derive", "wrap_help"] } -clap_complete = { version = "4.4" } -clap_complete_nushell = { version = "4.4" } +circular-buffer = "1.2" +clap = { version = "4.6", features = ["derive", "wrap_help"] } +clap_complete = { version = "4.6" } +clap_complete_nushell = { version = "4.6" } cocoa = "=0.26.0" cocoa-foundation = "=0.2.0" const_format = "0.2" convert_case = "0.11.0" core-foundation = "0.10" -core-foundation-sys = "0.8.6" +core-foundation-sys = "0.8.7" core-graphics = "0.24" core-text = "21" core-video = { version = "0.5.2", features = ["metal"] } cpal = "0.17" crash-handler = "0.7" criterion = { version = "0.5", features = ["html_reports"] } -ctor = "1.0.12" +ctor = "1.0.13" dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "1b461b310481d01e02b2603c16d7144b926339f8" } -dashmap = "6.0" +dashmap = "6.2" derive_more = { version = "2.1.1", features = [ "add", "add_assign", @@ -156,11 +156,11 @@ derive_more = { version = "2.1.1", features = [ "not", ] } dirs = "6.0" -documented = "0.9.1" -dotenvy = "0.15.0" +documented = "0.9.2" +dotenvy = "0.15.7" dunce = "1.0" ec4rs = { version = "1.2", features = ["allow-empty-values"] } -emojis = "0.6.1" +emojis = "0.6.4" env_logger = "0.11" encoding_rs = "0.8" etagere = "0.2" @@ -169,29 +169,29 @@ fancy-regex = "0.18.0" fork = "0.4.0" flume = "0.12" foreign-types = "0.5" -futures = "0.3.32" +futures = "0.3.34" futures-concurrency = "7.7.1" futures-lite = "1.13" -futures-util = "0.3.32" -gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } +futures-util = "0.3.34" +gh-workflow = { git = "https://github.com/tailcallhq/gh-workflow", rev = "fcb9540827b969e20b33ec3c90f74e17ba2a95e5" } globset = "0.4" -heapless = "0.9.2" -handlebars = "4.3" +heapless = "0.9.3" +handlebars = "4.5" heck = "0.5" hdrhistogram = "7" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } hex = "0.4.3" -human_bytes = "0.4.1" +human_bytes = "0.4.3" html5ever = "0.27.0" -http = "1.1" -http-body = "1.0" +http = "1.5" +http-body = "1.1" httparse = "1.10" -idna = "1.0" -ignore = "0.4.22" +idna = "1.1" +ignore = "0.4.33" # image's default features minus "avif", which only adds an encoder (rav1e); # decoding AVIF would additionally need the non-default "avif-native" feature. -image = { version = "0.25.1", default-features = false, features = [ +image = { version = "0.25.10", default-features = false, features = [ "bmp", "dds", "exr", @@ -209,29 +209,29 @@ image = { version = "0.25.1", default-features = false, features = [ "webp", ] } imara-diff = "0.2.0" -indexmap = { version = "2.7.0", features = ["serde"] } +indexmap = { version = "2.14.2", features = ["serde"] } indoc = "2" -inventory = "0.3.19" +inventory = "0.3.24" itertools = "0.14.0" -jsonschema = "0.37.0" -jsonwebtoken = "10.0" -jupyter-protocol = "1.4.0" +jsonschema = "0.37.4" +jsonwebtoken = "10.4" +jupyter-protocol = "1.5.0" jupyter-websocket-client = "1.1.0" libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" -libwebrtc = "0.3.26" -livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] } -log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } +libwebrtc = "0.3.46" +livekit = { version = "0.7.53", features = ["tokio", "rustls-tls-native-roots"] } +log = { version = "0.4.34", features = ["kv_unstable_serde", "serde"] } lru = "0.16" lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" } mach2 = "0.5" markup5ever_rcdom = "0.3.0" metal = "0.33" minidumper = "0.9" -moka = { version = "0.12.10", features = ["sync"] } +moka = { version = "0.12.16", features = ["sync"] } nanoid = "0.4" -nbformat = "1.2.0" +nbformat = "1.2.2" nix = "0.29" notify-rust = "4" nucleo = "0.5" @@ -274,44 +274,44 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "std", ] } objc2-user-notifications = "0.3" -open = "5.0.0" -ordered-float = "2.1.1" -palette = { version = "0.7.5", default-features = false, features = ["std"] } -parking_lot = "0.12.1" -partial-json-fixer = "0.5.3" +open = "5.4.3" +ordered-float = "2.10.1" +palette = { version = "0.7.7", default-features = false, features = ["std"] } +parking_lot = "0.12.5" +partial-json-fixer = "0.5.5" parse_int = "0.9" pathfinder_geometry = "0.5" -pciid-parser = "0.8.0" +pciid-parser = "0.8.1" pathdiff = "0.2" percent-encoding = "2.3.2" -pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } +pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } piper = "0.2" portable-pty = "0.9.0" postage = { version = "0.5", features = ["futures-traits"] } -pretty_assertions = { version = "1.3.0", features = ["unstable"] } -proc-macro2 = "1.0.93" +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +proc-macro2 = "1.0.107" profiling = "1" # replace this with main when #635 is merged -proptest = { git = "https://github.com/proptest-rs/proptest", rev = "3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b", features = ["attr-macro"] } +proptest = { git = "https://github.com/proptest-rs/proptest", rev = "a6f033cf83adfd55557b86e6065e6f4df054ec70", features = ["attr-macro"] } proptest-derive = "0.8.0" proxyvars = "0.2" prost = "0.9" prost-build = "0.9" prost-types = "0.9" pollster = "0.4.0" -pulldown-cmark = { version = "0.13.0", default-features = false } +pulldown-cmark = { version = "0.13.4", default-features = false } quick-xml = "0.38" -quote = "1.0.9" +quote = "1.0.47" rand = "0.9" -rayon = "1.8" +rayon = "1.12" raw-window-handle = "0.6" -regex = "1.5" +regex = "1.13" # WARNING: If you change this, you must also publish a new version of zed-reqwest to crates.io reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662463bda39148ba154100dd44d3fba5873a4", default-features = false, features = [ "charset", @@ -328,49 +328,49 @@ resvg = { version = "0.46.0", default-features = false, features = [ "memmap-fonts", "raster-images", ] } -rsa = "0.9.6" -runtimelib = { version = "1.4.0", default-features = false, features = [ +rsa = "0.9.10" +runtimelib = { version = "1.6.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } -rust-embed = { version = "8.11", features = ["include-exclude"] } -rustc-hash = "2.1.0" +rust-embed = { version = "8.12", features = ["include-exclude"] } +rustc-hash = "2.1.3" rustix = { version = "1.1", features = ["fs"] } -rustls = { version = "0.23.26" } -rustls-platform-verifier = "0.5.0" +rustls = { version = "0.23.44" } +rustls-platform-verifier = "0.5.3" # WARNING: If you change this, you must also publish a new version of zed-scap to crates.io scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } -schemars = { version = "1.0", features = ["indexmap2"] } +schemars = { version = "1.2", features = ["indexmap2"] } seccompiler = "0.5" semver = { version = "1.0", features = ["serde"] } -serde = { version = "1.0.221", features = ["derive", "rc"] } -serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } +serde = { version = "1.0.229", features = ["derive", "rc"] } +serde_json = { version = "1.0.151", features = ["preserve_order", "raw_value"] } serde_yaml_ng = "0.10" serde_json_lenient = { version = "0.2", features = [ "preserve_order", "raw_value", ] } serde_yaml = "0.9.34" -serde_path_to_error = "0.1.17" +serde_path_to_error = "0.1.20" serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "3.1" shlex = "1.3.0" simplelog = "0.12.2" -slotmap = "1.0.6" -smallvec = { version = "1.6", features = ["union", "const_new"] } +slotmap = "1.1.1" +smallvec = { version = "1.16", features = ["union", "const_new"] } smol = "2.0" sqlformat = "0.2" stacksafe = "1.0" streaming-iterator = "0.1" strsim = "0.11" strum = { version = "0.27.2", features = ["derive"] } -swash = "0.2.6" -syn = { version = "2.0.101", features = ["full", "extra-traits", "visit-mut"] } -sys-locale = "0.3.1" -sysinfo = "0.37.0" +swash = "0.2.10" +syn = { version = "2.0.119", features = ["full", "extra-traits", "visit-mut"] } +sys-locale = "0.3.2" +sysinfo = "0.37.2" take-until = "0.2.0" -tempfile = "3.20.0" -thiserror = "2.0.12" +tempfile = "3.27.0" +thiserror = "2.0.20" time = { version = "0.3", features = [ "macros", "parsing", @@ -388,14 +388,14 @@ toml_edit = { version = "0.22", default-features = false, features = [ "serde", ] } tower-http = "0.4.4" -tree-sitter = "0.26.9" +tree-sitter = "0.26.13" tree-sitter-bash = "0.25.1" -tree-sitter-c = "0.24.1" +tree-sitter-c = "0.24.2" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } tree-sitter-css = "0.23" tree-sitter-diff = "0.1.0" tree-sitter-elixir = "0.3" -tree-sitter-embedded-template = "0.23.0" +tree-sitter-embedded-template = "0.23.2" tree-sitter-gitcommit = { git = "https://github.com/zed-industries/tree-sitter-git-commit", rev = "88309716a69dd13ab83443721ba6e0b491d37ee9" } tree-sitter-go = "0.25" tree-sitter-go-mod = { git = "https://github.com/camdencheek/tree-sitter-go-mod", rev = "2e886870578eeba1927a2dc4bd2e2b3f598c5f9a", package = "tree-sitter-gomod" } @@ -411,19 +411,19 @@ tree-sitter-ruby = "0.23" tree-sitter-rust = "0.24.2" tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } -tracing = "0.1.40" -unicase = "2.6" +tracing = "0.1.44" +unicase = "2.9" unicode-bidi = { version = "0.3.18", default-features = false, features = [ "hardcoded-data", ] } -unicode-script = "0.5.7" -unicode-segmentation = "1.10" +unicode-script = "0.5.8" +unicode-segmentation = "1.13" unicode-width = "0.2" -unindent = "0.2.0" -url = "2.2" -urlencoding = "2.1.2" +unindent = "0.2.4" +url = "2.5" +urlencoding = "2.1.3" usvg = { version = "0.46.0", default-features = false } -uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } +uuid = { version = "1.26.0", features = ["v4", "v5", "v7", "serde"] } vte = { version = "0.15.0", features = ["ansi"] } walkdir = "2.5" wasm-encoder = "0.252" @@ -439,19 +439,19 @@ wasmtime = { version = "36", default-features = false, features = [ ] } wasmtime-wasi = "36" wax = "0.7" -which = "6.0.0" -wasm-bindgen = "0.2.120" +which = "6.0.3" +wasm-bindgen = "0.2.128" wasm-bindgen-futures = "0.4" js-sys = "0.3" console_error_panic_hook = "0.1.7" web-time = "1.1.0" -webrtc-sys = "0.3.23" +webrtc-sys = "0.3.43" wgpu = "29.0.4" windows-core = "0.61" -windows-registry = "0.6.0" +windows-registry = "0.6.1" yaml-rust2 = "0.8" -yawc = { git = "https://github.com/zed-industries/yawc", rev = "71a452f551cac178367eaac5d7418a09afa1f3a2", version = "0.3.3" } -zeroize = "1.8" +yawc = { git = "https://github.com/infinitefield/yawc", rev = "0f11f4193f36bbd93d18b480fb632d4ff72cf35f", version = "0.3.4" } +zeroize = "1.9" zstd = "0.11" @@ -564,6 +564,5 @@ nonminimal_bool = "allow" [patch.crates-io] -async-process = { git = "https://github.com/zed-industries/async-process.git", rev = "0b6d6713570af61806e1e5cb40e0f757cb93fd9d" } -async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } -calloop = { git = "https://github.com/zed-industries/calloop" } +async-process = { git = "https://github.com/smol-rs/async-process.git", rev = "f4485f156f9294b86a5be37f7236bcf0cf93c76b" } +async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } \ No newline at end of file diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 5715314..f4ba4aa 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -67,8 +67,8 @@ image.workspace = true inventory.workspace = true itertools.workspace = true log.workspace = true -num_cpus = "1.13" -parking = "2.0.0" +num_cpus = "1.17" +parking = "2.2.1" parking_lot.workspace = true postage.workspace = true proptest = { workspace = true, optional = true } @@ -100,8 +100,8 @@ gpui_util.workspace = true hdrhistogram = { workspace = true, optional = true } waker-fn = "1.2.0" lyon = "1.0" -pin-project = "1.1.10" -spin = "0.10.0" +pin-project = "1.1.13" +spin = "0.10.1" pollster.workspace = true url.workspace = true uuid.workspace = true diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 76d010e..f6ec33d 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -76,15 +76,15 @@ oo7 = { version = "0.6", default-features = false, features = [ "async-std", "native_crypto", ] } -calloop = "0.14.3" +calloop = "0.14.4" notify-rust.workspace = true raw-window-handle.workspace = true # Used in both windowing options ashpd = { workspace = true, optional = true } bitflags = { workspace = true, optional = true } -filedescriptor = { version = "0.8.2", optional = true } -open = { version = "5.2.0", optional = true } +filedescriptor = { version = "0.8.3", optional = true } +open = { version = "5.4.3", optional = true } xkbcommon = { version = "0.8.0", default-features = false, optional = true } # Screen capture @@ -92,28 +92,28 @@ scap = { workspace = true, optional = true } # Wayland calloop-wayland-source = { version = "0.4.1", optional = true } -wayland-backend = { version = "0.3.15", features = [ +wayland-backend = { version = "0.3.17", features = [ "client_system", "dlopen", "log", ], optional = true } -wayland-client = { version = "0.31.11", optional = true } -wayland-cursor = { version = "0.31.11", optional = true } -wayland-protocols = { version = "0.32.9", features = [ +wayland-client = { version = "0.31.15", optional = true } +wayland-cursor = { version = "0.31.14", optional = true } +wayland-protocols = { version = "0.32.13", features = [ "client", "staging", "unstable", ], optional = true } -wayland-protocols-plasma = { version = "0.3.9", features = [ +wayland-protocols-plasma = { version = "0.3.12", features = [ "client", ], optional = true } -wayland-protocols-wlr = { version = "0.3.9", features = [ +wayland-protocols-wlr = { version = "0.3.12", features = [ "client", ], optional = true } # X11 as-raw-xcb-connection = { version = "1", optional = true } -x11rb = { version = "0.13.1", features = [ +x11rb = { version = "0.13.2", features = [ "allow-unsafe-code", "xkb", "randr", diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index d1f3c0c..8aa5daa 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -54,7 +54,7 @@ walkdir.workspace = true dirs.workspace = true [target.'cfg(unix)'.dependencies] -command-fds = "0.3.1" +command-fds = "0.3.3" libc.workspace = true nix = { workspace = true, features = ["user"] } diff --git a/crates/ztracing/Cargo.toml b/crates/ztracing/Cargo.toml index e823785..982f382 100644 --- a/crates/ztracing/Cargo.toml +++ b/crates/ztracing/Cargo.toml @@ -16,13 +16,13 @@ web = ["dep:async-channel", "dep:js-sys", "dep:wasm-bindgen", "dep:web-sys"] zlog.workspace = true tracing.workspace = true -tracing-subscriber = "0.3.22" +tracing-subscriber = "0.3.23" ztracing_macro.workspace = true [target.'cfg(not(target_family = "wasm"))'.dependencies] tracing-tracy = { version = "0.11.4", optional = true, features = ["enable", "ondemand"] } -tracy-client = { version = "0.18.2", optional = true, features = ["enable", "ondemand"] } +tracy-client = { version = "0.18.5", optional = true, features = ["enable", "ondemand"] } [target.'cfg(target_family = "wasm")'.dependencies] async-channel = { workspace = true, optional = true }