diff --git a/crates/gpui/src/app/test_app.rs b/crates/gpui/src/app/test_app.rs index 268fa891b5..920ebe2017 100644 --- a/crates/gpui/src/app/test_app.rs +++ b/crates/gpui/src/app/test_app.rs @@ -451,6 +451,7 @@ impl TestAppWindow { delta: crate::ScrollDelta::Pixels(delta), modifiers: Default::default(), touch_phase: crate::TouchPhase::Moved, + ..Default::default() }); } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index c02bd16b77..6be7d7e1f6 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2386,7 +2386,9 @@ impl Interactivity { window, cx, ); - self.paint_scroll_listener(hitbox, &style, window, cx); + self.paint_scroll_listener( + hitbox, bounds, &style, window, cx, + ); } self.paint_keyboard_listeners(window, cx); @@ -3080,6 +3082,7 @@ impl Interactivity { fn paint_scroll_listener( &self, hitbox: &Hitbox, + bounds: Bounds, style: &Style, window: &mut Window, _cx: &mut App, @@ -3089,6 +3092,14 @@ impl Interactivity { let allow_concurrent_scroll = style.allow_concurrent_scroll; let restrict_scroll_to_axis = style.restrict_scroll_to_axis; let line_height = window.line_height(); + let padding = style + .padding + .to_pixels(bounds.size.into(), window.rem_size()); + let content_size = self.content_size + + size(padding.left + padding.right, padding.top + padding.bottom); + let scroll_max = Point::from(content_size - bounds.size) + .map(|value| (value * 100.).round() / 100.) + .max(&Point::default()); let hitbox = hitbox.clone(); let current_view = window.current_view(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { @@ -3120,8 +3131,8 @@ impl Interactivity { delta_x = Pixels::ZERO; } } - scroll_offset.y += delta_y; - scroll_offset.x += delta_x; + scroll_offset.y = (scroll_offset.y + delta_y).clamp(-scroll_max.y, px(0.)); + scroll_offset.x = (scroll_offset.x + delta_x).clamp(-scroll_max.x, px(0.)); if *scroll_offset != old_scroll_offset { cx.notify(current_view); } @@ -4121,6 +4132,49 @@ mod tests { }; use std::{cell::Cell, rc::Weak}; + #[gpui::test] + fn scroll_clamps_each_sample_and_skips_edge_notifications(cx: &mut TestAppContext) { + struct ScrollView(ScrollHandle); + impl Render for ScrollView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .id("scroll") + .size_full() + .p(px(5.)) + .overflow_y_scroll() + .track_scroll(&self.0) + .child(div().h(px(400.)).w_full()) + } + } + let cx = cx.add_empty_window(); + let handle = ScrollHandle::new(); + let view = cx.new(|_| ScrollView(handle.clone())); + cx.draw(Point::default(), size(px(100.), px(100.)), |_, _| { + view.clone().into_any_element() + }); + let notifications = Rc::new(Cell::new(0)); + let _subscription = cx.update(|_, cx| { + cx.observe(&view, { + let notifications = notifications.clone(); + move |_, _| notifications.set(notifications.get() + 1) + }) + }); + let scroll = |dy| ScrollWheelEvent { + position: point(px(10.), px(10.)), + delta: crate::ScrollDelta::Pixels(point(px(0.), px(dy))), + ..Default::default() + }; + for _ in 0..10 { + cx.simulate_event(scroll(10.)); + } + assert_eq!(notifications.get(), 0); + cx.simulate_event(scroll(-1000.)); + assert_eq!(handle.offset().y, px(-310.)); + cx.simulate_event(scroll(0.25)); + assert_eq!(handle.offset().y, px(-309.75)); + assert_eq!(notifications.get(), 2); + } + struct GroupHoverTestView { render_count: Rc>, anonymous_paint_count: Rc>, diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 6097a9f978..2de3af6849 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -10,7 +10,7 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges, Element, EntityId, FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, - Overflow, Pixels, Point, ScrollDelta, ScrollWheelEvent, Size, Style, StyleRefinement, Styled, + Overflow, Pixels, Point, ScrollGesture, ScrollWheelEvent, Size, Style, StyleRefinement, Styled, Window, point, px, size, }; use collections::VecDeque; @@ -67,6 +67,7 @@ struct StateInner { alignment: ListAlignment, overdraw: Pixels, reset: bool, + scroll_gesture_active: bool, #[allow(clippy::type_complexity)] scroll_handler: Option>, scrollbar_drag_start_height: Option, @@ -171,6 +172,7 @@ pub enum ListAlignment { } /// A scroll event that has been converted to be in terms of the list's items. +#[derive(Clone, Debug, Default)] pub struct ListScrollEvent { /// The range of items currently visible in the list, after applying the scroll event. pub visible_range: Range, @@ -183,6 +185,15 @@ pub struct ListScrollEvent { /// Whether the list is currently in follow-tail mode (auto-scrolling to end). pub is_following_tail: bool, + + /// The precise displacement requested by this input, before clamping. + pub delta: Point, + + /// Whether this input changed the visible scroll position. + pub position_changed: bool, + + /// Native contact and inertia lifecycle, including zero-delta events. + pub gesture: ScrollGesture, } /// The sizing behavior to apply during layout. @@ -323,6 +334,7 @@ impl ListState { overdraw, scroll_handler: None, reset: false, + scroll_gesture_active: false, scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, @@ -421,7 +433,8 @@ impl ListState { /// Reset this instantiation of the list state. /// - /// Note that this will cause scroll events to be dropped until the next paint. + /// Scroll movement is ignored until the next paint; native gesture phases + /// are still delivered so consumers can release ownership of the viewport. pub fn reset(&self, element_count: usize) { let old_count = { let state = &mut *self.0.borrow_mut(); @@ -959,42 +972,59 @@ impl StateInner { fn scroll( &mut self, - scroll_top: &ListOffset, height: Pixels, - delta: Point, + event: &ScrollWheelEvent, current_view: EntityId, window: &mut Window, cx: &mut App, ) { - // Drop scroll events after a reset, since we can't calculate - // the new logical scroll top without the item heights - if self.reset { + let delta = event.delta.pixel_delta(px(20.)); + if (self.reset || delta.y == px(0.)) && !event.gesture.is_phased() { return; } + if let Some(phase) = event.gesture.momentum_phase.or(event.gesture.touch_phase) { + self.scroll_gesture_active = + matches!(phase, crate::TouchPhase::Started | crate::TouchPhase::Moved); + } let padding = self.last_padding.unwrap_or_default(); let scroll_max = (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); - let new_scroll_top = (self.scroll_top(scroll_top) - delta.y) - .max(px(0.)) - .min(scroll_max); - - if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { - self.pending_scroll = None; - self.logical_scroll_top = None; + // Read the latest position, not the last painted position. Every event + // must clamp independently: an overshoot followed by a reversal before + // the next frame must move away from the edge immediately. + let old_scroll_top = self.logical_scroll_top.map_or_else( + || match self.alignment { + ListAlignment::Top => px(0.), + ListAlignment::Bottom => scroll_max, + }, + |offset| self.scroll_top(&offset), + ); + let new_scroll_top = if self.reset || delta.y == px(0.) { + old_scroll_top } else { - let (start, ..) = - self.items - .find::((), &Height(new_scroll_top), Bias::Right); - let scroll_top = ListOffset { - item_ix: start.count, - offset_in_item: new_scroll_top - start.height, - }; - // The user's scroll supersedes the position stashed by a - // remeasure; re-anchor the pending adjustment so it doesn't revert - // this scroll on the next layout. - self.rebase_pending_scroll(scroll_top); - self.logical_scroll_top = Some(scroll_top); + (old_scroll_top - delta.y).clamp(px(0.), scroll_max) + }; + let position_changed = new_scroll_top != old_scroll_top; + + if !self.reset && delta.y != px(0.) { + if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { + self.pending_scroll = None; + self.logical_scroll_top = None; + } else { + let (start, ..) = + self.items + .find::((), &Height(new_scroll_top), Bias::Right); + let scroll_top = ListOffset { + item_ix: start.count, + offset_in_item: new_scroll_top - start.height, + }; + // The user's scroll supersedes the position stashed by a + // remeasure; re-anchor the pending adjustment so it doesn't revert + // this scroll on the next layout. + self.rebase_pending_scroll(scroll_top); + self.logical_scroll_top = Some(scroll_top); + } } if delta.y > px(0.) { @@ -1002,7 +1032,16 @@ impl StateInner { } if let Some(handler) = self.scroll_handler.as_mut() { - let visible_range = Self::visible_range(&self.items, height, scroll_top); + // Bottom alignment represents a pinned list with `None`, whose + // logical offset is the end sentinel, not the visible first row. + let (start, ..) = + self.items + .find::((), &Height(new_scroll_top), Bias::Right); + let scroll_top = ListOffset { + item_ix: start.count, + offset_in_item: new_scroll_top - start.height, + }; + let visible_range = Self::visible_range(&self.items, height, &scroll_top); handler( &ListScrollEvent { visible_range, @@ -1012,13 +1051,18 @@ impl StateInner { self.follow_state, FollowState::Tail { is_following: true } ), + delta, + position_changed, + gesture: event.gesture, }, window, cx, ); } - cx.notify(current_view); + if position_changed { + cx.notify(current_view); + } } fn logical_scroll_top(&self) -> ListOffset { @@ -1729,21 +1773,29 @@ impl Element for List { let list_state = self.state.clone(); let height = bounds.size.height; - let scroll_top = prepaint.layout.scroll_top; let hitbox_id = prepaint.hitbox.id; - let mut accumulated_scroll_delta = ScrollDelta::default(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) { - accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta); - let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.)); - list_state.0.borrow_mut().scroll( - &scroll_top, - height, - pixel_delta, - current_view, - window, - cx, + list_state + .0 + .borrow_mut() + .scroll(height, event, current_view, window, cx) + } else if phase == DispatchPhase::Capture + && !hitbox_id.should_handle_scroll(window) + && matches!( + event.gesture.momentum_phase.or(event.gesture.touch_phase), + Some(crate::TouchPhase::Ended | crate::TouchPhase::Cancelled) ) + && list_state.0.borrow().scroll_gesture_active + { + // A pointer can leave the list before a native gesture ends. + // Deliver its termination without applying another view's delta. + let mut ending = event.clone(); + ending.delta = crate::ScrollDelta::Pixels(Point::default()); + list_state + .0 + .borrow_mut() + .scroll(height, &ending, current_view, window, cx); } }); } @@ -1853,7 +1905,7 @@ mod test { use crate::{ self as gpui, AppContext, Bounds, Context, Element, FollowMode, IntoElement, ListState, - Render, Styled, TestAppContext, Window, canvas, div, list, point, px, size, + Point, Render, Styled, TestAppContext, Window, canvas, div, list, point, px, size, }; #[gpui::test] @@ -2037,6 +2089,93 @@ mod test { } } + #[gpui::test] + fn precise_scroll_applies_each_sample_before_paint(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)).measure_all(); + let view = cx.new(|_| TestListView(state.clone())); + cx.draw(Point::default(), size(px(100.), px(100.)), |_, _| { + view.clone().into_any_element() + }); + let notifications = Rc::new(Cell::new(0)); + let _subscription = cx.update(|_, cx| { + cx.observe(&view, { + let notifications = notifications.clone(); + move |_, _| notifications.set(notifications.get() + 1) + }) + }); + let scroll = |dy| ScrollWheelEvent { + position: point(px(1.), px(1.)), + delta: ScrollDelta::Pixels(point(px(0.), px(dy))), + ..Default::default() + }; + // Repeated input at the top changes no position and invalidates no view. + for _ in 0..10 { + cx.simulate_event(scroll(2.)); + } + assert_eq!(notifications.get(), 0); + cx.simulate_event(scroll(-12.5)); + cx.simulate_event(scroll(2.25)); + cx.simulate_event(scroll(0.)); + assert_eq!(state.scroll_px_offset_for_scrollbar().y, px(-10.25)); + // Overshoot must be discarded at the edge before applying the reversal. + cx.simulate_event(scroll(-1000.)); + cx.simulate_event(scroll(0.25)); + assert_eq!(state.scroll_px_offset_for_scrollbar().y, px(-299.75)); + assert_eq!( + notifications.get(), + 4, + "only samples that move the list notify" + ); + } + + #[gpui::test] + fn native_scroll_phases_reach_handler_without_movement(cx: &mut TestAppContext) { + use crate::{ScrollGesture, TouchPhase}; + let cx = cx.add_empty_window(); + let state = ListState::new(20, crate::ListAlignment::Bottom, px(10.)).measure_all(); + let events = Rc::new(std::cell::RefCell::new(Vec::new())); + state.set_scroll_handler({ + let events = events.clone(); + move |event, _, _| events.borrow_mut().push(event.clone()) + }); + let view = cx.new(|_| TestListView(state.clone())); + cx.draw(Point::default(), size(px(100.), px(100.)), |_, _| { + view.clone().into_any_element() + }); + let notifications = Rc::new(Cell::new(0)); + let _subscription = cx.update(|_, cx| { + cx.observe(&view, { + let notifications = notifications.clone(); + move |_, _| notifications.set(notifications.get() + 1) + }) + }); + for (phase, position) in [ + (TouchPhase::Started, point(px(1.), px(1.))), + (TouchPhase::Ended, point(px(150.), px(150.))), + ] { + cx.simulate_event(ScrollWheelEvent { + position, + gesture: ScrollGesture { + momentum_phase: Some(phase), + ..Default::default() + }, + ..Default::default() + }); + } + assert_eq!( + events.borrow().len(), + 2, + "termination must survive leaving the hitbox" + ); + for event in events.borrow().iter() { + assert!(!event.position_changed); + assert_eq!(event.visible_range.start, 15); + assert!(event.visible_range.end >= 20); + } + assert_eq!(notifications.get(), 0); + } + #[gpui::test] fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) { let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 1feee49edb..9ba53d480c 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -3,7 +3,7 @@ use crate::{ Window, point, seal::Sealed, }; use smallvec::SmallVec; -use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf}; +use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf, time::Duration}; /// An event from a platform input source. pub trait InputEvent: Sealed + 'static { @@ -508,6 +508,23 @@ impl MouseMoveEvent { } } +/// Native scroll lifecycle information. Unphased wheel events leave both fields +/// unset. Momentum is supplied by the platform, not synthesized by GPUI. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ScrollGesture { + /// The phase while the user is touching the scrolling device. + pub touch_phase: Option, + /// The phase of the platform's inertial scrolling after release. + pub momentum_phase: Option, +} + +impl ScrollGesture { + /// Whether the platform supplied a scroll lifecycle for this event. + pub fn is_phased(self) -> bool { + self.touch_phase.is_some() || self.momentum_phase.is_some() + } +} + /// A mouse wheel event from the platform. #[derive(Clone, Debug, Default)] pub struct ScrollWheelEvent { @@ -522,6 +539,14 @@ pub struct ScrollWheelEvent { /// The phase of the touch event. pub touch_phase: TouchPhase, + + /// Contact and momentum phases, when supplied by the platform. Use this + /// instead of `touch_phase` to distinguish inertial scrolling from contact. + pub gesture: ScrollGesture, + + /// The native event time on the platform's monotonic clock. Its origin is + /// platform-specific; it must not be compared directly with `Instant`. + pub timestamp: Option, } impl Sealed for ScrollWheelEvent {} @@ -610,26 +635,12 @@ impl ScrollDelta { } /// Combines two scroll deltas into one. - /// If the signs of the deltas are the same (both positive or both negative), - /// the deltas are added together. If the signs are opposite, the second delta - /// (other) is used, effectively overriding the first delta. + /// Precise deltas are additive, including reversals and zero-delta lifecycle + /// events. Discrete wheel deltas retain the latest direction. This does not + /// replace applying individual events when clamping at scroll boundaries. pub fn coalesce(self, other: ScrollDelta) -> ScrollDelta { match (self, other) { - (ScrollDelta::Pixels(a), ScrollDelta::Pixels(b)) => { - let x = if a.x.signum() == b.x.signum() { - a.x + b.x - } else { - b.x - }; - - let y = if a.y.signum() == b.y.signum() { - a.y + b.y - } else { - b.y - }; - - ScrollDelta::Pixels(point(x, y)) - } + (ScrollDelta::Pixels(a), ScrollDelta::Pixels(b)) => ScrollDelta::Pixels(a + b), (ScrollDelta::Lines(a), ScrollDelta::Lines(b)) => { let x = if a.x.signum() == b.x.signum() { @@ -806,6 +817,16 @@ impl PlatformInput { #[cfg(test)] mod test { + #[test] + fn precise_scroll_coalescing_preserves_reversals_and_zero_events() { + use crate::{ScrollDelta, point, px}; + let first = ScrollDelta::Pixels(point(px(0.25), px(-12.5))); + let reversed = first.coalesce(ScrollDelta::Pixels(point(px(-0.1), px(2.25)))); + assert_eq!(reversed.pixel_delta(px(20.)), point(px(0.15), px(-10.25))); + let ended = reversed.coalesce(ScrollDelta::Pixels(point(px(0.), px(0.)))); + assert_eq!(ended.pixel_delta(px(20.)), reversed.pixel_delta(px(20.))); + } + use crate::{ self as gpui, AppContext as _, Context, FocusHandle, InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Render, TestAppContext, Window, div, diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 7ad023bdcc..f5d11b9896 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -719,6 +719,10 @@ pub struct RequestFrameOptions { pub require_presentation: bool, /// Force refresh of all rendering states when true. pub force_render: bool, + /// The display is known to use a fixed refresh rate. Unchanged scenes need + /// not be presented to prevent variable-refresh-rate downclocking after + /// high-rate input. Defaults to false for platforms with unknown timing. + pub fixed_refresh_rate: bool, } /// The application's lifecycle phase, as owned and reported by a mobile OS. diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 8c477f9321..8a03e421a0 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -97,6 +97,11 @@ impl TestWindow { /// Deliver one display tick if the window has requested frames. pub fn simulate_display_tick(&self) -> bool { + self.simulate_display_tick_with_options(RequestFrameOptions::default()) + } + + /// Deliver a requested display tick with the given platform timing options. + pub fn simulate_display_tick_with_options(&self, options: RequestFrameOptions) -> bool { let mut state = self.0.lock(); if !state.frame_requested.get() { return false; @@ -105,7 +110,7 @@ impl TestWindow { return false; }; drop(state); - callback(RequestFrameOptions::default()); + callback(options); self.0.lock().request_frame_callback = Some(callback); true } diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 0218115c38..548e7a06e3 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -811,6 +811,7 @@ pub struct MonochromeSprite { pub color: Hsla, pub tile: AtlasTile, pub transformation: TransformationMatrix, + pub fade: EdgeFadeParams, } impl From for Primitive { @@ -830,6 +831,7 @@ pub struct SubpixelSprite { pub color: Hsla, pub tile: AtlasTile, pub transformation: TransformationMatrix, + pub fade: EdgeFadeParams, } impl From for Primitive { @@ -1249,6 +1251,7 @@ mod tests { bounds: bounds(), content_mask: ContentMask { bounds: bounds() }, color: crate::black(), + fade: EdgeFadeParams::default(), tile: AtlasTile { texture_id: AtlasTextureId { index, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index b9436cbe21..bab754b7b5 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1583,12 +1583,14 @@ impl Window { .log_err(); } - // Keep presenting if input was recently arriving at a high rate (>= 60fps). - // Once high-rate input is detected, we sustain presentation for 1 second - // to prevent display underclocking during active input. + // Sustain presentation after high-rate input only when the display + // may downclock. On fixed-rate displays, presenting an unchanged + // scene consumes GPU work and can block input on drawable acquisition. let needs_present = request_frame_options.require_presentation || needs_present.get() - || (active.get() && input_rate_tracker.borrow_mut().is_high_rate()); + || (!request_frame_options.fixed_refresh_rate + && active.get() + && input_rate_tracker.borrow().is_high_rate()); if invalidator.is_dirty() || request_frame_options.force_render { measure("frame duration", || { @@ -1614,6 +1616,9 @@ impl Window { handle .update(&mut cx, |_, window, _| { window.complete_frame(); + // Keep frame callbacks armed between high-rate input events, + // even when duplicate presentation is disabled. Parking here + // would repeatedly restart the source and trim GPU resources. if !window.invalidator.is_dirty() && !window.needs_present.get() && window.next_frame_callbacks.borrow().is_empty() @@ -3501,9 +3506,9 @@ impl Window { /// Executes the provided function with a vertical [`EdgeFade`] applied: /// every primitive painted inside is additionally faded by its vertical /// position — full alpha in the region's body, ramping to zero across - /// `fade.band` at each active edge. Granularity is per-primitive (each - /// quad/glyph/sprite takes the ramp value at its own position), which - /// reads as a smooth gradient for text and small marks. + /// `fade.band` at each active edge. Quads, text glyphs, and images evaluate + /// the ramp per fragment, so even a glyph crossing an edge fades smoothly. + /// Paths, decorations, and SVG icons retain conservative primitive opacity. pub fn with_edge_fade( &mut self, fade: Option, @@ -4295,10 +4300,7 @@ impl Window { ) -> Result<()> { self.invalidator.debug_assert_paint(); - let element_opacity = self.element_opacity_for_bounds(&Bounds { - origin, - size: size(font_size * 0.6, font_size), - }); + let element_opacity = self.element_opacity(); let scale_factor = self.scale_factor(); let glyph_origin = origin.scale(scale_factor); @@ -4350,6 +4352,7 @@ impl Window { color: color.opacity(element_opacity), tile, transformation: TransformationMatrix::unit(), + fade: self.scaled_edge_fade(), }); } else { self.next_frame.scene.insert_primitive(MonochromeSprite { @@ -4360,6 +4363,7 @@ impl Window { color: color.opacity(element_opacity), tile, transformation: TransformationMatrix::unit(), + fade: self.scaled_edge_fade(), }); } } @@ -4509,6 +4513,7 @@ impl Window { color: color.opacity(element_opacity), tile, transformation, + fade: Default::default(), }); Ok(()) @@ -6847,8 +6852,8 @@ pub fn outline( mod tests { use crate::{ AppContext as _, Bounds, Context, FocusHandle, InteractiveElement as _, IntoElement, - ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window, canvas, div, px, - size, + ParentElement as _, Pixels, Render, RequestFrameOptions, Styled as _, TestAppContext, + Window, canvas, div, px, size, }; use std::{cell::Cell, rc::Rc}; @@ -6999,6 +7004,99 @@ mod tests { } } + #[test] + fn high_rate_input_only_repeats_presentations_for_variable_refresh() { + for fixed_refresh_rate in [true, false] { + let mut cx = TestAppContext::single(); + let desired = Rc::new(Cell::new(1)); + let displayed = Rc::new(Cell::new(0)); + let calls = Rc::new(Cell::new(0)); + let handle = cx.add_window(|_, _| NativeFrameProbe { + desired: desired.clone(), + displayed: displayed.clone(), + calls: calls.clone(), + }); + let platform = cx + .update_window(handle.into(), |_, window, _| { + window.active.set(true); + // Exercise the same high-rate detector as a burst of input, + // without depending on a real display or wall-clock sleeps. + for _ in 0..6 { + window.input_rate_tracker.borrow_mut().record_input(); + } + assert!(window.input_rate_tracker.borrow().is_high_rate()); + window.platform_window.as_test().unwrap().clone() + }) + .unwrap(); + let options = RequestFrameOptions { + fixed_refresh_rate, + ..Default::default() + }; + + assert!(platform.simulate_display_tick_with_options(options)); + assert_eq!(displayed.get(), 1); + assert_eq!(calls.get(), 1); + for _ in 0..24 { + assert!( + platform.simulate_display_tick_with_options(options), + "the frame source must stay armed between high-rate events" + ); + } + let repeated = if fixed_refresh_rate { 0 } else { 24 }; + assert_eq!(calls.get(), 1 + repeated); + + // A content update still reaches the next display tick. + handle + .update(&mut cx, |_, _, cx| { + desired.set(2); + cx.notify(); + }) + .unwrap(); + assert!(platform.simulate_display_tick_with_options(options)); + assert_eq!(displayed.get(), 2); + assert_eq!(calls.get(), 2 + repeated); + + // Animation callbacks and explicit presentation requests are not + // subject to suppression of unchanged scenes. + handle + .update(&mut cx, |_, window, _| { + let desired = desired.clone(); + window.on_next_frame(move |window, _| { + desired.set(3); + window.refresh(); + }); + }) + .unwrap(); + assert!(platform.simulate_display_tick_with_options(options)); + assert_eq!(displayed.get(), 3); + assert_eq!(calls.get(), 3 + repeated); + assert!( + platform.simulate_display_tick_with_options(RequestFrameOptions { + require_presentation: true, + ..options + }) + ); + assert_eq!(calls.get(), 4 + repeated); + assert!( + platform.simulate_display_tick_with_options(RequestFrameOptions { + force_render: true, + ..options + }) + ); + assert_eq!(calls.get(), 5 + repeated); + + // Both modes must park once the high-rate input grace period ends. + handle + .update(&mut cx, |_, window, _| { + window.input_rate_tracker.replace(Default::default()); + }) + .unwrap(); + assert!(platform.simulate_display_tick_with_options(options)); + assert_eq!(calls.get(), 5 + repeated); + assert!(!platform.simulate_display_tick_with_options(options)); + } + } + #[test] fn native_updates_wait_for_the_presented_frame() { let mut cx = TestAppContext::single(); diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 7b719e97fd..205b9ace1d 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -2257,6 +2257,7 @@ impl Dispatch for WaylandClientStatePtr { delta: ScrollDelta::Pixels(continuous), modifiers: state.modifiers, touch_phase: TouchPhase::Moved, + ..Default::default() }); drop(state); window.handle_input(input); @@ -2269,6 +2270,7 @@ impl Dispatch for WaylandClientStatePtr { delta: ScrollDelta::Lines(discrete), modifiers: state.modifiers, touch_phase: TouchPhase::Moved, + ..Default::default() }); drop(state); window.handle_input(input); diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 4c9b71b726..935fe8189b 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -2005,6 +2005,7 @@ impl X11ClientState { window.refresh(RequestFrameOptions { require_presentation: expose_event_received, force_render, + ..Default::default() }); } xcb_connection @@ -2510,6 +2511,7 @@ fn make_scroll_wheel_event( delta: ScrollDelta::Lines(delta), modifiers, touch_phase: TouchPhase::default(), + ..Default::default() } } diff --git a/crates/gpui_macos/src/events.rs b/crates/gpui_macos/src/events.rs index 71bcb105e8..fd7c59e186 100644 --- a/crates/gpui_macos/src/events.rs +++ b/crates/gpui_macos/src/events.rs @@ -2,7 +2,7 @@ use gpui::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta, - ScrollWheelEvent, TouchPhase, point, px, + ScrollGesture, ScrollWheelEvent, TouchPhase, point, px, }; use crate::{ @@ -16,7 +16,58 @@ use cocoa::{ use core_foundation::data::{CFDataGetBytePtr, CFDataRef}; use core_graphics::event::CGKeyCode; use objc::{msg_send, sel, sel_impl}; -use std::{borrow::Cow, ffi::c_void}; +use std::{borrow::Cow, ffi::c_void, time::Duration}; + +fn scroll_phase(phase: NSEventPhase) -> Option { + match phase { + NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { + Some(TouchPhase::Started) + } + NSEventPhase::NSEventPhaseChanged | NSEventPhase::NSEventPhaseStationary => { + Some(TouchPhase::Moved) + } + NSEventPhase::NSEventPhaseEnded => Some(TouchPhase::Ended), + NSEventPhase::NSEventPhaseCancelled => Some(TouchPhase::Cancelled), + _ => None, + } +} + +#[cfg(test)] +mod scroll_tests { + use super::*; + + #[test] + fn native_contact_and_momentum_phases_remain_distinct() { + assert_eq!(scroll_phase(NSEventPhase::NSEventPhaseNone), None); + assert_eq!( + scroll_phase(NSEventPhase::NSEventPhaseMayBegin), + Some(TouchPhase::Started) + ); + assert_eq!( + scroll_phase(NSEventPhase::NSEventPhaseBegan), + Some(TouchPhase::Started) + ); + assert_eq!( + scroll_phase(NSEventPhase::NSEventPhaseChanged), + Some(TouchPhase::Moved) + ); + assert_eq!( + scroll_phase(NSEventPhase::NSEventPhaseEnded), + Some(TouchPhase::Ended) + ); + assert_eq!( + scroll_phase(NSEventPhase::NSEventPhaseCancelled), + Some(TouchPhase::Cancelled) + ); + let momentum = ScrollGesture { + touch_phase: scroll_phase(NSEventPhase::NSEventPhaseNone), + momentum_phase: scroll_phase(NSEventPhase::NSEventPhaseChanged), + }; + assert!(momentum.is_phased()); + assert!(momentum.touch_phase.is_none()); + assert_eq!(momentum.momentum_phase, Some(TouchPhase::Moved)); + } +} const BACKSPACE_KEY: u16 = 0x7f; const SPACE_KEY: u16 = b' ' as u16; @@ -256,12 +307,9 @@ pub(crate) unsafe fn platform_input_from_native( }) }), NSEventType::NSScrollWheel => window_height.map(|window_height| { - let phase = match native_event.phase() { - NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { - TouchPhase::Started - } - NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, - _ => TouchPhase::Moved, + let gesture = ScrollGesture { + touch_phase: scroll_phase(native_event.phase()), + momentum_phase: scroll_phase(native_event.momentumPhase()), }; let raw_data = point( @@ -281,7 +329,9 @@ pub(crate) unsafe fn platform_input_from_native( window_height - px(native_event.locationInWindow().y as f32), ), delta, - touch_phase: phase, + touch_phase: gesture.touch_phase.unwrap_or_default(), + gesture, + timestamp: Duration::try_from_secs_f64(native_event.timestamp()).ok(), modifiers: read_modifiers(native_event), }) }), diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 793bdd2cd9..f124dff41e 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -2499,6 +2499,80 @@ mod backdrop_blur_tests { max } + #[test] + fn monochrome_fade_varies_within_a_glyph_and_moves_continuously() { + use gpui::{ + EdgeFadeParams, MonochromeSprite, PlatformAtlas, RenderSvgParams, TransformationMatrix, + }; + use std::borrow::Cow; + assert!( + metal::Device::system_default().is_some(), + "requires Metal GPU" + ); + let mut renderer = + MetalRenderer::new_headless(Arc::new(Mutex::new(InstanceBufferPool::default()))); + let tile_size = size(DevicePixels(32), DevicePixels(16)); + let tile = renderer + .sprite_atlas() + .get_or_insert_with( + &RenderSvgParams { + path: "fade-test-mask".into(), + size: tile_size, + } + .into(), + &mut || Ok(Some((tile_size, Cow::Owned(vec![255; 32 * 16])))), + ) + .unwrap() + .unwrap(); + let mut paint = |edge: f32, band: f32| { + let mut scene = Scene::default(); + push_quad(&mut scene, viewport(), 0.0); + // A solid glyph-sized mask isolates the opacity ramp from glyph + // shape and antialiasing. A whole-glyph fade cannot pass this. + scene.insert_primitive(MonochromeSprite { + order: 0, + pad: 0, + bounds: bounds(20., 20., 32., 16.), + content_mask: ContentMask { bounds: viewport() }, + color: Hsla { + h: 0., + s: 0., + l: 1., + a: 1., + }, + tile, + transformation: TransformationMatrix::unit(), + fade: EdgeFadeParams { + right_x: edge, + band_right: band, + ..Default::default() + }, + }); + scene.finish(); + renderer + .render_scene_to_image(&scene, size(DevicePixels(VIEW_W), DevicePixels(VIEW_H))) + .unwrap() + }; + let plain = paint(52., 0.); + assert_eq!(plain.get_pixel(40, 28)[0], 255); + let faded = paint(52., 20.); + for x in 33..51 { + let expected = (((52. - (x as f32 + 0.5)) / 20.).powi(2) * 255.).round() as i32; + assert!( + (faded.get_pixel(x, 28)[0] as i32 - expected).abs() <= 2, + "x={x}" + ); + } + let shifted = paint(52.5, 20.); + for x in 33..51 { + let delta = shifted.get_pixel(x, 28)[0] as i32 - faded.get_pixel(x, 28)[0] as i32; + assert!( + (0..=14).contains(&delta), + "discontinuous fade at x={x}: {delta}" + ); + } + } + #[test] fn image_alpha_mask_matches_reference_and_reuses_texture_across_frames() { use gpui::{ImageAlphaMaskParams, PlatformAtlas, RenderImage, RenderImageParams}; diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal index e67988377e..1839331558 100644 --- a/crates/gpui_macos/src/shaders.metal +++ b/crates/gpui_macos/src/shaders.metal @@ -629,6 +629,7 @@ struct MonochromeSpriteVertexOutput { float4 position [[position]]; float2 tile_position; float4 color [[flat]]; + uint sprite_id [[flat]]; float4 clip_distance; }; @@ -636,6 +637,7 @@ struct MonochromeSpriteFragmentInput { float4 position [[position]]; float2 tile_position; float4 color [[flat]]; + uint sprite_id [[flat]]; float4 clip_distance; }; @@ -659,6 +661,7 @@ vertex MonochromeSpriteVertexOutput monochrome_sprite_vertex( device_position, tile_position, color, + sprite_id, {clip_distance.x, clip_distance.y, clip_distance.z, clip_distance.w}}; } @@ -675,7 +678,7 @@ fragment float4 monochrome_sprite_fragment( float4 sample = atlas_texture.sample(atlas_texture_sampler, input.tile_position); float4 color = input.color; - color.a *= sample.a; + color.a *= sample.a * edge_fade_alpha(input.position.xy, sprites[input.sprite_id].fade); return color; } diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 308f2dcc1d..9f967a0c56 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -582,6 +582,7 @@ struct MacWindowState { cursor_visible: Arc, frame_source: Option, frame_requested: Arc, + fixed_refresh_rate: bool, renderer: renderer::Renderer, overlay_renderer: Option, overlay_view: Option>, @@ -765,7 +766,11 @@ impl MacWindowState { return; } } - let Some(display_id) = display_id_for_screen(unsafe { self.native_window.screen() }) else { + let screen = unsafe { self.native_window.screen() }; + // Re-evaluate on screen changes and when waking a parked frame source, + // rather than querying AppKit on every display tick. + self.fixed_refresh_rate = screen_has_fixed_refresh_rate(screen); + let Some(display_id) = display_id_for_screen(screen) else { // AppKit can temporarily report no screen while displays are being reconfigured. return; }; @@ -995,6 +1000,7 @@ impl MacWindow { cursor_visible, frame_source: None, frame_requested: Arc::new(AtomicBool::new(true)), + fixed_refresh_rate: false, renderer: renderer::new_renderer( renderer_context, native_window as *mut _, @@ -3035,8 +3041,12 @@ extern "C" fn step(view: *mut c_void) { let mut lock = window_state.lock(); if let Some(mut callback) = lock.request_frame_callback.take() { + let options = RequestFrameOptions { + fixed_refresh_rate: lock.fixed_refresh_rate, + ..Default::default() + }; drop(lock); - callback(Default::default()); + callback(options); window_state.lock().request_frame_callback = Some(callback); } } @@ -3397,6 +3407,27 @@ where } } +fn screen_has_fixed_refresh_rate(screen: id) -> bool { + if screen.is_null() { + return false; + } + + unsafe { + // These properties were introduced in macOS 12. Preserve the existing + // presentation policy if the display's timing is unavailable. + let has_intervals: BOOL = + msg_send![screen, respondsToSelector: sel!(minimumRefreshInterval)]; + if has_intervals == NO { + return false; + } + let minimum: f64 = msg_send![screen, minimumRefreshInterval]; + let maximum: f64 = msg_send![screen, maximumRefreshInterval]; + // NSScreen documents equal intervals for fixed-rate displays. A zero + // or otherwise invalid interval does not identify a fixed-rate mode. + minimum.is_finite() && minimum > 0.0 && minimum == maximum + } +} + fn display_id_for_screen(screen: id) -> Option { if screen.is_null() { return None; @@ -3604,4 +3635,9 @@ mod tests { fn display_id_for_screen_returns_none_for_null_screen() { assert_eq!(display_id_for_screen(nil), None); } + + #[test] + fn missing_screen_keeps_variable_refresh_presentation() { + assert!(!screen_has_fixed_refresh_rate(nil)); + } } diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 08434aada4..8a61d6bdf9 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -266,6 +266,7 @@ impl WebWindowInner { delta, modifiers, touch_phase: TouchPhase::Moved, + ..Default::default() })); }) } diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index b9399d32e6..b81da204b5 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -312,6 +312,7 @@ impl WebWindowInner { callback(RequestFrameOptions { require_presentation: true, force_render: false, + ..Default::default() }); } } diff --git a/crates/gpui_wgpu/src/gpui_wgpu.rs b/crates/gpui_wgpu/src/gpui_wgpu.rs index 243bfc326b..c8f634670d 100644 --- a/crates/gpui_wgpu/src/gpui_wgpu.rs +++ b/crates/gpui_wgpu/src/gpui_wgpu.rs @@ -75,4 +75,52 @@ mod shader_tests { } } } + #[test] + fn text_fade_shaders_validate_and_match_host_layout() { + use std::mem::{offset_of, size_of}; + let source = format!( + "enable dual_source_blending;\n{}\n{}", + include_str!("shaders.wgsl"), + include_str!("shaders_subpixel.wgsl"), + ); + let module = naga::front::wgsl::parse_str(&source).unwrap(); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap(); + for (name, size, fade_offset) in [ + ( + "MonochromeSprite", + size_of::(), + offset_of!(gpui::MonochromeSprite, fade), + ), + ( + "SubpixelSprite", + size_of::(), + offset_of!(gpui::SubpixelSprite, fade), + ), + ] { + let ty = module + .types + .iter() + .find(|(_, ty)| ty.name.as_deref() == Some(name)) + .unwrap() + .1; + let naga::TypeInner::Struct { members, span } = &ty.inner else { + panic!("expected struct") + }; + assert_eq!(*span as usize, size, "{name} stride"); + assert_eq!( + members + .iter() + .find(|m| m.name.as_deref() == Some("fade")) + .unwrap() + .offset as usize, + fade_offset, + "{name}.fade" + ); + } + } } diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index 9b2f4544e5..daa3d18e20 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -1277,6 +1277,7 @@ struct MonochromeSprite { color: Hsla, tile: AtlasTile, transformation: TransformationMatrix, + fade: EdgeFadeParams, } @group(1) @binding(0) var b_mono_sprites: array; @@ -1285,6 +1286,7 @@ struct MonoSpriteVarying { @location(0) tile_position: vec2, @location(1) @interpolate(flat) color: vec4, @location(3) clip_distances: vec4, + @location(4) @interpolate(flat) sprite_id: u32, } @vertex @@ -1297,6 +1299,7 @@ fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index out.tile_position = to_tile_position(unit_vertex, sprite.tile); out.color = hsla_to_rgba(sprite.color); + out.sprite_id = instance_id; out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); return out; } @@ -1311,7 +1314,7 @@ fn fs_mono_sprite(input: MonoSpriteVarying) -> @location(0) vec4 { return vec4(0.0); } - return blend_color(input.color, alpha_corrected); + return blend_color(input.color, alpha_corrected * edge_fade_alpha(input.position.xy, b_mono_sprites[input.sprite_id].fade)); } // --- polychrome sprites --- // diff --git a/crates/gpui_wgpu/src/shaders_subpixel.wgsl b/crates/gpui_wgpu/src/shaders_subpixel.wgsl index 8fd936469d..5b516d3c5c 100644 --- a/crates/gpui_wgpu/src/shaders_subpixel.wgsl +++ b/crates/gpui_wgpu/src/shaders_subpixel.wgsl @@ -8,6 +8,7 @@ struct SubpixelSprite { color: Hsla, tile: AtlasTile, transformation: TransformationMatrix, + fade: EdgeFadeParams, } @group(1) @binding(0) var b_subpixel_sprites: array; @@ -16,6 +17,7 @@ struct SubpixelSpriteOutput { @location(0) tile_position: vec2, @location(1) @interpolate(flat) color: vec4, @location(3) clip_distances: vec4, + @location(4) @interpolate(flat) sprite_id: u32, } struct SubpixelSpriteFragmentOutput { @@ -32,6 +34,7 @@ fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_i out.position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation); out.tile_position = to_tile_position(unit_vertex, sprite.tile); out.color = hsla_to_rgba(sprite.color); + out.sprite_id = instance_id; out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); return out; } @@ -51,6 +54,6 @@ fn fs_subpixel_sprite(input: SubpixelSpriteOutput) -> SubpixelSpriteFragmentOutp var out = SubpixelSpriteFragmentOutput(); out.foreground = vec4(input.color.rgb, 1.0); - out.alpha = vec4(input.color.a * alpha_corrected, 1.0); + out.alpha = vec4(input.color.a * alpha_corrected * edge_fade_alpha(input.position.xy, b_subpixel_sprites[input.sprite_id].fade), 1.0); return out; } diff --git a/crates/gpui_windows/src/direct_manipulation.rs b/crates/gpui_windows/src/direct_manipulation.rs index 9adcbed343..b98e2a3fe0 100644 --- a/crates/gpui_windows/src/direct_manipulation.rs +++ b/crates/gpui_windows/src/direct_manipulation.rs @@ -175,6 +175,7 @@ impl DirectManipulationEventHandler { delta: ScrollDelta::Pixels(point(px(0.0), px(0.0))), modifiers, touch_phase: TouchPhase::Ended, + ..Default::default() })); } GestureKind::Pinch => { @@ -329,6 +330,7 @@ impl IDirectManipulationViewportEventHandler_Impl for DirectManipulationEventHan delta: ScrollDelta::Pixels(point(px(dx), px(dy))), modifiers, touch_phase, + ..Default::default() })); } GestureKind::Pinch => { diff --git a/crates/gpui_windows/src/directx_renderer/backdrop_tests.rs b/crates/gpui_windows/src/directx_renderer/backdrop_tests.rs index cba1b34d12..6e73e9f7a5 100644 --- a/crates/gpui_windows/src/directx_renderer/backdrop_tests.rs +++ b/crates/gpui_windows/src/directx_renderer/backdrop_tests.rs @@ -812,6 +812,7 @@ fn warp_translucent_sprites_keep_backdrop_at_scaled_edges() -> Result<()> { bounds: sprite_bounds, content_mask, color: hsla(0.1, 0.6, 0.8, 0.6), + fade: EdgeFadeParams::default(), tile, transformation: TransformationMatrix::default(), }); diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index b293763bd9..1ddb74e904 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -533,6 +533,7 @@ impl WindowsWindowInner { }), modifiers, touch_phase: TouchPhase::Moved, + ..Default::default() }); let handled = !func(input).propagate; self.state.callbacks.input.set(Some(func)); @@ -571,6 +572,7 @@ impl WindowsWindowInner { }), modifiers: current_modifiers(), touch_phase: TouchPhase::Moved, + ..Default::default() }); let handled = !func(event).propagate; self.state.callbacks.input.set(Some(func)); @@ -1248,6 +1250,7 @@ impl WindowsWindowInner { request_frame(RequestFrameOptions { require_presentation: false, force_render, + ..Default::default() }); self.state.callbacks.request_frame.set(Some(request_frame)); diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index ebb066dffb..197cf26c05 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -1176,12 +1176,14 @@ struct MonochromeSprite { Hsla color; AtlasTile tile; TransformationMatrix transformation; + EdgeFadeParams fade; }; struct MonochromeSpriteVertexOutput { float4 position: SV_Position; float2 tile_position: POSITION; nointerpolation float4 color: COLOR; + nointerpolation uint sprite_id: TEXCOORD1; float4 clip_distance: SV_ClipDistance; }; @@ -1189,6 +1191,7 @@ struct MonochromeSpriteFragmentInput { float4 position: SV_Position; float2 tile_position: POSITION; nointerpolation float4 color: COLOR; + nointerpolation uint sprite_id: TEXCOORD1; float4 clip_distance: SV_ClipDistance; }; @@ -1207,6 +1210,7 @@ MonochromeSpriteVertexOutput monochrome_sprite_vertex(uint vertex_id: SV_VertexI output.position = device_position; output.tile_position = tile_position; output.color = color; + output.sprite_id = sprite_id; output.clip_distance = clip_distance; return output; } @@ -1214,7 +1218,7 @@ MonochromeSpriteVertexOutput monochrome_sprite_vertex(uint vertex_id: SV_VertexI float4 monochrome_sprite_fragment(MonochromeSpriteFragmentInput input): SV_Target { float sample = t_sprite.Sample(s_sprite, input.tile_position).r; float alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, grayscale_enhanced_contrast, gamma_ratios); - return float4(input.color.rgb, input.color.a * alpha_corrected); + return float4(input.color.rgb, input.color.a * alpha_corrected * edge_fade_alpha(input.position.xy, mono_sprites[input.sprite_id].fade)); } MonochromeSpriteVertexOutput subpixel_sprite_vertex(uint vertex_id: SV_VertexID, uint sprite_id: SV_InstanceID) { @@ -1230,7 +1234,7 @@ SubpixelSpriteFragmentOutput subpixel_sprite_fragment(MonochromeSpriteFragmentIn SubpixelSpriteFragmentOutput output; output.foreground = float4(input.color.rgb, 1.0f); - output.alpha = float4(input.color.a * alpha_corrected, 1.0f); + output.alpha = float4(input.color.a * alpha_corrected * edge_fade_alpha(input.position.xy, mono_sprites[input.sprite_id].fade), 1.0f); return output; }