fn main() {
app::run();
}
mod app {
use multitouch::{Contact, ContactStream, Device, Finger};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, ProtocolObject};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, NSBackingStoreType,
NSBezierPath, NSColor, NSFont, NSFontAttributeName, NSFontWeightMedium,
NSFontWeightRegular, NSForegroundColorAttributeName, NSMenu, NSMenuItem, NSStringDrawing,
NSView, NSWindow, NSWindowStyleMask,
};
use objc2_foundation::{
NSDictionary, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSTimer,
};
use std::cell::RefCell;
const TRACKPAD_ASPECT: f64 = 1.6;
const HEADER_HEIGHT: f64 = 44.0;
const FOOTER_HEIGHT: f64 = 176.0;
const MARGIN: f64 = 24.0;
const MAX_LISTED: usize = 8;
struct ViewState {
device: Device,
stream: ContactStream,
contacts: RefCell<Vec<Contact>>,
}
define_class!(
#[unsafe(super(NSView))]
#[thread_kind = MainThreadOnly]
#[name = "MultitouchVisualizerView"]
#[ivars = ViewState]
struct TouchView;
impl TouchView {
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
#[unsafe(method(tick:))]
fn tick(&self, _timer: &NSTimer) {
let state = self.ivars();
let mut latest = None;
while let Some(frame) = state.stream.try_recv() {
latest = Some(frame);
}
if let Some(frame) = latest {
*state.contacts.borrow_mut() = frame;
self.setNeedsDisplay(true);
}
}
#[unsafe(method(drawRect:))]
fn draw_rect(&self, _dirty: NSRect) {
let state = self.ivars();
draw(self.bounds(), &state.device, &state.contacts.borrow());
}
}
);
impl TouchView {
fn new(mtm: MainThreadMarker, frame: NSRect, state: ViewState) -> Retained<Self> {
let this = mtm.alloc::<Self>().set_ivars(state);
unsafe { msg_send![super(this), initWithFrame: frame] }
}
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "MultitouchVisualizerDelegate"]
struct AppDelegate;
unsafe impl NSObjectProtocol for AppDelegate {}
unsafe impl NSApplicationDelegate for AppDelegate {
#[unsafe(method(applicationShouldTerminateAfterLastWindowClosed:))]
fn should_terminate_after_last_window_closed(&self, _app: &NSApplication) -> bool {
true
}
}
);
impl AppDelegate {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = mtm.alloc::<Self>().set_ivars(());
unsafe { msg_send![super(this), init] }
}
}
pub fn run() {
if !Device::is_available() {
eprintln!("MultitouchSupport is unavailable on this Mac.");
return;
}
let Some(device) = Device::default() else {
eprintln!("No multitouch device found.");
return;
};
println!("using {} ({:?})", device.name(), device.device_id());
if !device.start() {
eprintln!("Failed to start device.");
return;
}
let stream = device.contact_frames();
let mtm = MainThreadMarker::new().expect("must run on the main thread");
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
let delegate = AppDelegate::new(mtm);
app.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
install_menu(mtm, &app);
let content = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(860.0, 720.0));
let style = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::Miniaturizable
| NSWindowStyleMask::Resizable;
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(
mtm.alloc(),
content,
style,
NSBackingStoreType::Buffered,
false,
)
};
unsafe { window.setReleasedWhenClosed(false) };
window.setTitle(&NSString::from_str("Multitouch Visualizer"));
window.setMinSize(NSSize::new(560.0, 520.0));
let view = TouchView::new(
mtm,
content,
ViewState {
device,
stream,
contacts: RefCell::new(Vec::new()),
},
);
window.setContentView(Some(&view));
window.center();
window.makeKeyAndOrderFront(None);
let target: &AnyObject = &view;
let _timer = unsafe {
NSTimer::scheduledTimerWithTimeInterval_target_selector_userInfo_repeats(
1.0 / 120.0,
target,
sel!(tick:),
None,
true,
)
};
app.activate();
app.run();
}
fn install_menu(mtm: MainThreadMarker, app: &NSApplication) {
let main_menu = NSMenu::new(mtm);
let app_item = NSMenuItem::new(mtm);
main_menu.addItem(&app_item);
let app_menu = NSMenu::new(mtm);
let quit = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str("Quit Multitouch Visualizer"),
Some(sel!(terminate:)),
&NSString::from_str("q"),
)
};
app_menu.addItem(&quit);
app_item.setSubmenu(Some(&app_menu));
app.setMainMenu(Some(&main_menu));
}
fn finger_color(contact: &Contact) -> Retained<NSColor> {
if contact.is_palm() {
return NSColor::systemGrayColor();
}
match contact.finger() {
Some(Finger::Thumb) => NSColor::systemRedColor(),
Some(Finger::Index) => NSColor::systemOrangeColor(),
Some(Finger::Middle) => NSColor::systemGreenColor(),
Some(Finger::Ring) => NSColor::systemBlueColor(),
Some(Finger::Pinky) => NSColor::systemPurpleColor(),
None => NSColor::systemGrayColor(),
}
}
fn finger_label(contact: &Contact) -> &'static str {
if contact.is_palm() {
return "P";
}
match contact.finger() {
Some(Finger::Thumb) => "1",
Some(Finger::Index) => "2",
Some(Finger::Middle) => "3",
Some(Finger::Ring) => "4",
Some(Finger::Pinky) => "5",
None => "•",
}
}
fn draw_text(
text: &str,
x: f64,
y: f64,
size: f64,
bold: bool,
color: &NSColor,
centered: bool,
) {
let weight = unsafe {
if bold {
NSFontWeightMedium
} else {
NSFontWeightRegular
}
};
let font = NSFont::monospacedSystemFontOfSize_weight(size, weight);
let font_obj: &AnyObject = &font;
let color_obj: &AnyObject = color;
let attrs = unsafe {
NSDictionary::from_slices(
&[NSFontAttributeName, NSForegroundColorAttributeName],
&[font_obj, color_obj],
)
};
let string = NSString::from_str(text);
unsafe {
let mut origin = NSPoint::new(x, y);
if centered {
let extent = string.sizeWithAttributes(Some(&attrs));
origin.x -= extent.width / 2.0;
origin.y -= extent.height / 2.0;
}
string.drawAtPoint_withAttributes(origin, Some(&attrs));
}
}
fn draw(bounds: NSRect, device: &Device, contacts: &[Contact]) {
let width = bounds.size.width;
let height = bounds.size.height;
NSColor::windowBackgroundColor().setFill();
NSBezierPath::fillRect(bounds);
let label = NSColor::labelColor();
let secondary = NSColor::secondaryLabelColor();
let visible: Vec<&Contact> = contacts
.iter()
.filter(|c| {
!matches!(
c.state(),
multitouch::ContactState::NotTracking | multitouch::ContactState::OutOfRange
)
})
.collect();
draw_text(&device.name(), MARGIN, 14.0, 15.0, true, &label, false);
let summary = format!(
"{} contact{}",
visible.len(),
if visible.len() == 1 { "" } else { "s" }
);
draw_text(&summary, MARGIN, 32.0 - 2.0, 11.0, false, &secondary, false);
let avail_w = (width - MARGIN * 2.0).max(1.0);
let avail_h = (height - HEADER_HEIGHT - FOOTER_HEIGHT - MARGIN).max(1.0);
let (pad_w, pad_h) = if avail_w / avail_h > TRACKPAD_ASPECT {
(avail_h * TRACKPAD_ASPECT, avail_h)
} else {
(avail_w, avail_w / TRACKPAD_ASPECT)
};
let pad = NSRect::new(
NSPoint::new(
(width - pad_w) / 2.0,
HEADER_HEIGHT + (avail_h - pad_h) / 2.0,
),
NSSize::new(pad_w, pad_h),
);
let surface = NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius(pad, 18.0, 18.0);
NSColor::controlBackgroundColor().setFill();
surface.fill();
NSColor::separatorColor().setStroke();
surface.setLineWidth(1.5);
surface.stroke();
for contact in &visible {
let p = contact.normalized.position;
let cx = pad.origin.x + p.x.clamp(0.0, 1.0) as f64 * pad.size.width;
let cy = pad.origin.y + (1.0 - p.y.clamp(0.0, 1.0)) as f64 * pad.size.height;
let color = finger_color(contact);
let active = contact.state().is_active();
let alpha = if active { 1.0 } else { 0.4 };
let radius =
(8.0 + contact.major_axis.max(contact.minor_axis) as f64 * 1.2).clamp(14.0, 34.0);
let v = contact.normalized.velocity;
let speed = ((v.x * v.x + v.y * v.y) as f64).sqrt();
if speed > 0.01 {
let scale = (pad.size.width * 0.12).min(90.0);
let tail = NSPoint::new(cx + v.x as f64 * scale, cy - v.y as f64 * scale);
color.colorWithAlphaComponent(0.6).setStroke();
let line = NSBezierPath::bezierPath();
line.setLineWidth(3.0);
line.moveToPoint(NSPoint::new(cx, cy));
line.lineToPoint(tail);
line.stroke();
}
let halo = radius + (contact.pressure as f64 / 40.0).clamp(0.0, 14.0);
let halo_rect = NSRect::new(
NSPoint::new(cx - halo, cy - halo),
NSSize::new(halo * 2.0, halo * 2.0),
);
color.colorWithAlphaComponent(0.18 * alpha).setFill();
NSBezierPath::bezierPathWithOvalInRect(halo_rect).fill();
let rect = NSRect::new(
NSPoint::new(cx - radius, cy - radius),
NSSize::new(radius * 2.0, radius * 2.0),
);
let dot = NSBezierPath::bezierPathWithOvalInRect(rect);
color.colorWithAlphaComponent(0.85 * alpha).setFill();
dot.fill();
color.colorWithAlphaComponent(alpha).setStroke();
dot.setLineWidth(2.0);
dot.stroke();
draw_text(
finger_label(contact),
cx,
cy,
13.0,
true,
&NSColor::whiteColor(),
true,
);
}
let mut y = pad.origin.y + pad.size.height + 16.0;
draw_text(
"1–5 = classified finger P = palm line = velocity halo = pressure",
MARGIN,
y,
11.0,
false,
&secondary,
false,
);
y += 20.0;
for contact in visible.iter().take(MAX_LISTED) {
let finger = contact
.finger()
.map(|f| f.to_string())
.unwrap_or_else(|| "palm".into());
let line = format!(
"id {:>2} {:>9} {:>6} pos({:>5.3},{:>5.3}) vel({:>6.3},{:>6.3}) p {:>6.1} d {:>5.2} axes {:>5.2}/{:>5.2}",
contact.id,
contact.state(),
finger,
contact.normalized.position.x,
contact.normalized.position.y,
contact.normalized.velocity.x,
contact.normalized.velocity.y,
contact.pressure,
contact.density,
contact.major_axis,
contact.minor_axis,
);
draw_text(&line, MARGIN, y, 11.0, false, &label, false);
y += 15.0;
}
}
}