diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 95454317d6..648ce4a5f5 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -202,6 +202,10 @@ path = "examples/learn/text.rs" name = "transition" path = "examples/learn/transition.rs" +[[example]] +name = "haptic_feedback" +path = "examples/learn/haptic_feedback.rs" + [[example]] name = "blur" path = "examples/learn/blur.rs" diff --git a/crates/gpui/examples/learn/haptic_feedback.rs b/crates/gpui/examples/learn/haptic_feedback.rs new file mode 100644 index 0000000000..a29a656b4b --- /dev/null +++ b/crates/gpui/examples/learn/haptic_feedback.rs @@ -0,0 +1,332 @@ +//! Haptic Feedback Example +//! +//! This example demonstrates haptic feedback in GPUI (macOS only): +//! +//! - Check if haptic feedback is supported +//! - Play the three [`NSHapticFeedbackPattern`] styles +//! - A quantized slider with haptic feedback +//! +//! On macOS, haptics are delivered via `NSHapticFeedbackManager`. On other +//! platforms, the calls are currently no-ops. + +#[path = "../shared/prelude.rs"] +mod example_prelude; + +use example_prelude::init_example; +use gpui::{ + App, Bounds, Context, DragMoveEvent, FontWeight, HapticFeedbackStyle, Hsla, InteractiveElement, + IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Render, + StatefulInteractiveElement, Styled, Window, WindowBounds, WindowOptions, colors::Colors, div, + prelude::*, px, relative, rgb, size, +}; + +const SLIDER_MIN: f32 = 0.0; +const SLIDER_MAX: f32 = 100.0; +const SLIDER_STEP: f32 = 10.0; +const SLIDER_STEP_COUNT: usize = ((SLIDER_MAX - SLIDER_MIN) / SLIDER_STEP) as usize; + +#[derive(Clone)] +struct SliderDrag; + +impl Render for SliderDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +struct HapticFeedbackExample { + supported: bool, + slider_value: f32, + slider_prev_step: i32, + slider_bounds: Option>, +} + +impl HapticFeedbackExample { + fn new(cx: &mut App) -> Self { + Self { + supported: cx.supports_haptic_feedback(), + slider_value: 0.0, + slider_prev_step: 0, + slider_bounds: None, + } + } + + fn haptic_button( + &self, + id: &'static str, + label: &'static str, + style: HapticFeedbackStyle, + color: Hsla, + colors: &Colors, + cx: &mut Context, + ) -> impl IntoElement { + let _ = cx; + + div() + .id(id) + .flex() + .flex_col() + .w_48() + .items_center() + .justify_center() + .px_4() + .py_3() + .rounded_md() + .bg(color) + .cursor_pointer() + .hover(move |s| s.bg(color.opacity(0.8))) + .active(move |s| s.bg(color.opacity(0.6))) + .child( + div() + .text_base() + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(colors.selected_text)) + .child(label), + ) + .on_hover(move |hovered, _, cx| { + if *hovered { + cx.play_haptic_feedback(style); + } + }) + } + + fn value_to_step(value: f32) -> i32 { + ((value - SLIDER_MIN) / SLIDER_STEP).round() as i32 + } + + fn snap_to_step(raw: f32) -> f32 { + let snapped = (raw / SLIDER_STEP).round() * SLIDER_STEP; + snapped.clamp(SLIDER_MIN, SLIDER_MAX) + } + + fn value_to_percentage(value: f32) -> f32 { + ((value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)).clamp(0.0, 1.0) + } + + fn update_slider_from_position( + &mut self, + position_x: Pixels, + bounds: Bounds, + cx: &mut Context, + ) { + let inner_x = (position_x - bounds.left()).clamp(px(0.), bounds.size.width); + let percentage = inner_x / bounds.size.width; + let raw = SLIDER_MIN + (SLIDER_MAX - SLIDER_MIN) * percentage; + let new_value = Self::snap_to_step(raw); + let new_step = Self::value_to_step(new_value); + + self.slider_value = new_value; + self.slider_bounds = Some(bounds); + + if new_step != self.slider_prev_step { + cx.play_haptic_feedback(HapticFeedbackStyle::LevelChange); + self.slider_prev_step = new_step; + } + + cx.notify(); + } +} + +impl Render for HapticFeedbackExample { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = Colors::for_appearance(window); + let slider_percentage = Self::value_to_percentage(self.slider_value); + let slider_color = Hsla::from(rgb(0x3b82f6)); + + div() + .flex() + .flex_col() + .size_full() + .bg(colors.background) + .p_8() + .gap_6() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_2xl() + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.text) + .child("Haptic Feedback"), + ) + .child( + div().text_sm().text_color(colors.disabled).child( + "macOS Force Touch trackpad haptics via NSHapticFeedbackManager", + ), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .px_4() + .py_2() + .rounded_md() + .bg(if self.supported { + Hsla::from(rgb(0x22c55e)).opacity(0.1) + } else { + Hsla::from(rgb(0xf59e0b)).opacity(0.1) + }) + .child({ + let dot = if self.supported { + rgb(0x22c55e) + } else { + rgb(0xf59e0b) + }; + div().size_2().rounded_full().bg(dot) + }) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.text) + .when_else( + self.supported, + |this| this.child("Haptics is supported on this machine"), + |this| this.child("Haptics is not supported on this machine"), + ), + ), + ) + .child( + div() + .child("Hover over each button to trigger haptic feedback") + .text_sm() + .text_color(colors.text), + ) + .child( + div() + .flex() + .gap_3() + .child(self.haptic_button( + "btn-generic", + "Generic", + HapticFeedbackStyle::Generic, + Hsla::from(rgb(0x3b82f6)), + &colors, + cx, + )) + .child(self.haptic_button( + "btn-alignment", + "Alignment", + HapticFeedbackStyle::Alignment, + Hsla::from(rgb(0x10b981)), + &colors, + cx, + )) + .child(self.haptic_button( + "btn-levelchange", + "LevelChange", + HapticFeedbackStyle::LevelChange, + Hsla::from(rgb(0x8b5cf6)), + &colors, + cx, + )), + ) + .child( + div() + .flex() + .flex_col() + .gap_3() + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.text) + .child("Slider with LevelChange haptic"), + ) + .child( + div().flex().items_center().gap_2().child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.selected_text) + .child(format!("Value: {:.0}", self.slider_value)), + ), + ) + .child( + div() + .id("slider-container") + .w_full() + .h_4() + .flex() + .items_center() + .px_2() + .cursor_pointer() + .on_drag(SliderDrag, |_, _, _, cx| cx.new(|_| SliderDrag)) + .on_drag_move(cx.listener( + |this, e: &DragMoveEvent, _, cx| { + this.update_slider_from_position( + e.event.position.x, + e.bounds, + cx, + ); + }, + )) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, e: &MouseDownEvent, _, cx| { + if let Some(bounds) = this.slider_bounds { + this.update_slider_from_position(e.position.x, bounds, cx); + } + }), + ) + .child( + div() + .w_full() + .h_1p5() + .rounded_full() + .bg(Hsla::from(colors.text).opacity(0.12)) + .flex() + .items_center() + .justify_between() + .children((0..(SLIDER_STEP_COUNT + 1)).map(|_| { + div() + .size_1p5() + .rounded_full() + .bg(colors.disabled) + .into_any() + })) + .child( + div() + .absolute() + .h_full() + .left(px(0.)) + .right(relative(1.0 - slider_percentage)) + .bg(slider_color.opacity(0.7)) + .rounded_full(), + ) + .child( + div() + .absolute() + .top(px(-6.)) + .left(relative(slider_percentage)) + .ml(px(-8.)) + .size_4() + .rounded_full() + .bg(slider_color) + .shadow_md(), + ), + ), + ), + ) + } +} + +fn main() { + gpui_platform::application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(520.), px(520.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|cx| HapticFeedbackExample::new(cx)), + ) + .expect("Failed to open window"); + + init_example(cx, "Haptic Feedback"); + }); +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 236885728b..594d68bf01 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -44,8 +44,8 @@ use crate::{ Action, ActionBuildError, ActionRegistry, Any, AnyView, AnyWindowHandle, AppContext, Arena, ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, - KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, - PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, + HapticFeedbackStyle, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, + OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextRenderingMode, TextSystem, @@ -1272,6 +1272,21 @@ impl App { self.platform.window_appearance() } + /// Whether the current platform supports haptic feedback. + pub fn supports_haptic_feedback(&self) -> bool { + self.platform.supports_haptic_feedback() + } + + /// Play a haptic feedback of the given style. + /// + /// Must be called from the main thread. This is a no-op on platforms that + /// do not support haptic feedback. Styles correspond to + /// [`NSHapticFeedbackPattern`](https://developer.apple.com/documentation/appkit/nshapticfeedbackmanager/feedbackpattern) + /// values on macOS. + pub fn play_haptic_feedback(&self, style: HapticFeedbackStyle) { + self.platform.play_haptic_feedback(style) + } + /// Returns the window button layout configuration when supported. pub fn button_layout(&self) -> Option { self.platform.button_layout() diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 32125b6a78..4f4ebbe467 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -252,6 +252,16 @@ pub trait Platform: 'static { /// Sets the label applied to credentials stored in the system keyring. /// Only Linux/FreeBSD use this label. fn set_keyring_label(&self, _label: SharedString) {} + + /// Whether the current platform supports haptic feedback. + fn supports_haptic_feedback(&self) -> bool { + false + } + + /// Play a haptic feedback of the given style. + /// + /// No-op on platforms that don't support haptic feedback. + fn play_haptic_feedback(&self, _style: HapticFeedbackStyle) {} } /// A handle to a platform's display, e.g. a monitor or laptop screen. @@ -298,6 +308,21 @@ pub enum ThermalState { Critical, } +/// Styles of haptic feedback that can be played via the platform. +/// +/// These correspond directly to [`NSHapticFeedbackPattern`](https://developer.apple.com/documentation/appkit/nshapticfeedbackmanager/feedbackpattern) +/// values on macOS. On other platforms, all styles are no-ops. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HapticFeedbackStyle { + /// A generic haptic tap — suitable for most interactions. + Generic, + /// A sharp snap — for alignment guides, detents, and snapping. + Alignment, + /// A distinct level-change click — for slider steps, toggles, and + /// discrete state changes. + LevelChange, +} + /// Metadata for a given [ScreenCaptureSource] #[derive(Clone)] pub struct SourceMetadata { diff --git a/crates/gpui_macos/src/gpui_macos.rs b/crates/gpui_macos/src/gpui_macos.rs index b9b55430b8..63e542bb4d 100644 --- a/crates/gpui_macos/src/gpui_macos.rs +++ b/crates/gpui_macos/src/gpui_macos.rs @@ -8,6 +8,7 @@ mod dispatcher; mod display; mod display_link; mod events; +mod haptic_feedback; mod keyboard; mod pasteboard; diff --git a/crates/gpui_macos/src/haptic_feedback.rs b/crates/gpui_macos/src/haptic_feedback.rs new file mode 100644 index 0000000000..21926218d7 --- /dev/null +++ b/crates/gpui_macos/src/haptic_feedback.rs @@ -0,0 +1,94 @@ +use cocoa::base::id; +use gpui::HapticFeedbackStyle; +use objc::{class, msg_send, sel, sel_impl}; + +/// macOS haptic feedback using [`NSHapticFeedbackManager`]. +/// +/// Delivers transient taps through the Force Touch trackpad (macOS 10.11+). +/// Fire and forget. On machines without haptic hardware, calls are silently ignored by AppKit. +pub(crate) struct MacHaptics { + supported: bool, +} + +/// +#[allow(dead_code)] +mod feedback_pattern { + pub const GENERIC: isize = 0; + pub const ALIGNMENT: isize = 1; + pub const LEVEL_CHANGE: isize = 2; +} + +impl MacHaptics { + pub fn new(headless: bool) -> Self { + Self { + supported: !headless, + } + } + + pub fn supported(&self) -> bool { + self.supported + } + + fn pattern_for_style(style: HapticFeedbackStyle) -> isize { + match style { + HapticFeedbackStyle::Generic => feedback_pattern::GENERIC, + HapticFeedbackStyle::Alignment => feedback_pattern::ALIGNMENT, + HapticFeedbackStyle::LevelChange => feedback_pattern::LEVEL_CHANGE, + } + } + + pub fn play(&self, style: HapticFeedbackStyle) { + if !self.supported { + return; + } + + let pattern = Self::pattern_for_style(style); + + /// + const PERFORMANCE_TIME_NOW: usize = 1; + + // Safety: NSHapticFeedbackManager is always available on macOS 10.11+. + // All Platform trait methods run on the main thread. + unsafe { + let manager: id = msg_send![class!(NSHapticFeedbackManager), defaultPerformer]; + let _: () = msg_send![ + manager, + performFeedbackPattern: pattern + performanceTime: PERFORMANCE_TIME_NOW + ]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_supported() { + let haptics = MacHaptics::new(false); + assert!(haptics.supported()); + } + + #[test] + fn test_headless_is_unsupported() { + let haptics = MacHaptics::new(true); + assert!(!haptics.supported()); + } + + #[test] + fn test_style_to_pattern_mapping() { + assert_eq!( + MacHaptics::pattern_for_style(HapticFeedbackStyle::Generic), + feedback_pattern::GENERIC + ); + assert_eq!( + MacHaptics::pattern_for_style(HapticFeedbackStyle::Alignment), + feedback_pattern::ALIGNMENT + ); + assert_eq!( + MacHaptics::pattern_for_style(HapticFeedbackStyle::LevelChange), + feedback_pattern::LEVEL_CHANGE + ); + } +} diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 87346991d6..e5c9623365 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -1,7 +1,7 @@ use crate::{ BoolExt, MacDispatcher, MacDisplay, MacKeyboardLayout, MacKeyboardMapper, MacWindow, - events::key_to_native, ns_string, pasteboard::Pasteboard, renderer, - set_active_window_cursor_style, + events::key_to_native, haptic_feedback::MacHaptics, ns_string, pasteboard::Pasteboard, + renderer, set_active_window_cursor_style, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; @@ -184,6 +184,8 @@ pub(crate) struct MacPlatformState { keyboard_mapper: Rc, /// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose. cursor_visible: Arc, + /// Haptic feedback engine (macOS only, lazy-initialized on first use). + haptics: MacHaptics, } impl MacPlatform { @@ -221,6 +223,7 @@ impl MacPlatform { menus: None, keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), + haptics: MacHaptics::new(headless), })) } @@ -1150,6 +1153,14 @@ impl Platform for MacPlatform { Ok(()) }) } + + fn supports_haptic_feedback(&self) -> bool { + self.0.lock().haptics.supported() + } + + fn play_haptic_feedback(&self, style: gpui::HapticFeedbackStyle) { + self.0.lock().haptics.play(style) + } } unsafe fn path_from_objc(path: id) -> PathBuf {