Whitespace only; the fork has no own rustfmt.toml so the Oak root policy applies.
333 lines
7.8 KiB
Rust
333 lines
7.8 KiB
Rust
//! 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");
|
|
});
|
|
}
|