feat(macos): support haptic feedback (#71)
* Support haptic feedback on macOS * add tests for haptic feedback
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ mod dispatcher;
|
||||
mod display;
|
||||
mod display_link;
|
||||
mod events;
|
||||
mod haptic_feedback;
|
||||
mod keyboard;
|
||||
mod pasteboard;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user