feat(macos): support haptic feedback (#71)

* Support haptic feedback on macOS

* add tests for haptic feedback
This commit is contained in:
Brendon Felix
2026-08-04 14:52:28 -07:00
committed by GitHub
parent 33ed975bf2
commit 568271c01c
7 changed files with 486 additions and 4 deletions
+4
View File
@@ -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"
@@ -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<Self>) -> impl IntoElement {
gpui::Empty
}
}
struct HapticFeedbackExample {
supported: bool,
slider_value: f32,
slider_prev_step: i32,
slider_bounds: Option<Bounds<Pixels>>,
}
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<Self>,
) -> 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<Pixels>,
cx: &mut Context<Self>,
) {
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<Self>) -> 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<SliderDrag>, _, 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");
});
}
+17 -2
View File
@@ -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<WindowButtonLayout> {
self.platform.button_layout()
+25
View File
@@ -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 {
+1
View File
@@ -8,6 +8,7 @@ mod dispatcher;
mod display;
mod display_link;
mod events;
mod haptic_feedback;
mod keyboard;
mod pasteboard;
+94
View File
@@ -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,
}
/// <https://developer.apple.com/documentation/appkit/nshapticfeedbackmanager/feedbackpattern>
#[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);
/// <https://developer.apple.com/documentation/appkit/nshapticfeedbackmanager/performancetime>
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
);
}
}
+13 -2
View File
@@ -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<MacKeyboardMapper>,
/// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose.
cursor_visible: Arc<AtomicBool>,
/// 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 {