Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ Open `TinyHand.dmg`, drag TinyHand to Applications and allow **Accessibility** w
| **Esc** | Clear everything and turn the pointer off |

While the pointer is on, a small toolbar at the bottom of the screen lets you pick one of
36 hands or a laser dot, mute the tap sound, or exit.
36 hands or a laser dot, set how far the stick swings on a tap (Swing range, from Off to 2×),
mute the tap sound, or exit.

## Automation

Expand Down
116 changes: 100 additions & 16 deletions Sources/TinyHand/ArmView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,34 @@ final class ArmView: NSView {
private let liftFrac: CGFloat = 0.06

let ink = Ink()
var held = false
var held = false {
didSet {
if oldValue && !held {
releasedAt = CACurrentMediaTime()
}
}
}
var inkWidth: CGFloat { max(3, bounds.height * 0.0055) }

private var pressAmount: CGFloat = 0
private var tapStart: CFTimeInterval?
private var swingRange = CGFloat(Preferences.swingRange)
private var releasedAt: CFTimeInterval = 0

/// Seconds from press() until the fingertip lands on the spot (wind-up + swing-in).
/// The tap sound waits this long so it plays on contact.
static let swingWindUp: CFTimeInterval = 0.13
static let swingIn: CFTimeInterval = 0.09
static var strikeDelay: CFTimeInterval { swingWindUp + swingIn }
private static let settleTime: CFTimeInterval = 0.12
private var gripX: CGFloat?
private var lastPose: [CGFloat] = []
private var lastLaserPose: [CGFloat] = []
private var gripTarget: CGFloat?
private var displayImageHeight = 0

var isSettling: Bool {
if pressAmount > 0.002 {
if pressAmount > 0.002 || swing(at: CACurrentMediaTime()).press > 0.002 {
return true
}
guard let gx = gripX, let target = gripTarget else {
Expand Down Expand Up @@ -82,6 +98,7 @@ final class ArmView: NSView {
NotificationCenter.default.addObserver(forName: Preferences.changed, object: nil, queue: .main) { [weak self] _ in
self?.handLenFrac = Preferences.pointerSize
self?.showShadow = Preferences.showShadow
self?.swingRange = CGFloat(Preferences.swingRange)
}
}

Expand Down Expand Up @@ -114,8 +131,69 @@ final class ArmView: NSView {
fatalError("init(coder:) not used")
}

/// At swing range 0 the tap is the original one: no swing, just the small shrink.
private var swings: Bool { swingRange > 0.001 }

/// How long the tap sound waits so it plays when the fingertip lands.
var tapSoundDelay: CFTimeInterval { swings && style != .laser ? ArmView.strikeDelay : 0 }

func press() {
pressAmount = 1
tapStart = swings ? CACurrentMediaTime() : nil
}

/// Jump straight to the moment the fingertip touches (used when a drag starts, and by renders).
func skipSwing() {
if tapStart != nil {
tapStart = CACurrentMediaTime() - ArmView.strikeDelay
}
}

/// The tap: the stick lifts up (and a little right), the holding hand rising with it,
/// then swings down onto the spot and presses. No oscillation.
/// Distances are fractions of the screen height; press is 1 on the glass, negative when lifted.
private struct Swing {
var press: CGFloat = 0
var poke: CGFloat = 0
var tilt: CGFloat = 0
var tipX: CGFloat = 0
var tipY: CGFloat = 0
var gripX: CGFloat = 0
var gripY: CGFloat = 0
}

private func swing(at now: CFTimeInterval) -> Swing {
guard let start = tapStart else {
return Swing()
}
let t = now - start
let up = ArmView.swingWindUp
let strike = ArmView.strikeDelay
let k = swingRange
func pose(_ e: CGFloat, press: CGFloat) -> Swing {
let tipX: CGFloat = 0.025 * k
let tipY: CGFloat = 0.2 * k
return Swing(press: press, poke: 0.02 * k * e, tilt: -0.03 * k * e,
tipX: tipX * e, tipY: tipY * e, gripX: 0.45 * tipX * e, gripY: 0.45 * tipY * e)
}
if t < up {
let e = 1 - pow(1 - CGFloat(t / up), 3)
return pose(e, press: -0.6 * e)
}
if t < strike {
let e = pow(CGFloat((t - up) / (strike - up)), 2)
return pose(1 - e, press: -0.6 + 1.6 * e)
}
if held {
return Swing(press: 1)
}
let since = now - max(start + strike, releasedAt)
let press = CGFloat(exp(-since / ArmView.settleTime))
if press < 0.001 {
tapStart = nil
return Swing()
}
return Swing(press: press)
}

func aim(cursor: CGPoint) {
Expand All @@ -124,12 +202,16 @@ final class ArmView: NSView {
let gx = gripX.map { $0 + (targetX - $0) * gripFollow } ?? targetX
gripX = gx
gripTarget = targetX
let grip = CGPoint(x: gx, y: -gripDepthFrac * screenH)

if !held {
pressAmount += (0 - pressAmount) * 0.18
}

if ink.isDrawing {
skipSwing()
}
let sw = swing(at: CACurrentMediaTime())
let grip = CGPoint(x: gx + sw.gripX * screenH, y: (sw.gripY - gripDepthFrac) * screenH)

if style == .laser {
aimLaser(cursor: cursor)
return
Expand All @@ -139,12 +221,17 @@ final class ArmView: NSView {
}
updateDisplayImage(a)

let dx = cursor.x - grip.x
let dy = cursor.y - grip.y
let toCursor = CGPoint(x: cursor.x - grip.x, y: cursor.y - grip.y)
let dist = max(1, hypot(toCursor.x, toCursor.y))
let poke = sw.poke * screenH
let dx = toCursor.x - toCursor.x / dist * poke + sw.tipX * screenH
let dy = toCursor.y - toCursor.y / dist * poke + sw.tipY * screenH
let reach = hypot(dx, dy)
let aimAngle = atan2(dy, dx)

let handPts = handLenFrac * screenH * (1 - 0.06 * pressAmount)
// Swing range 0 keeps the original press: pressAmount and a 6% shrink.
let press = swings ? sw.press : pressAmount
let handPts = handLenFrac * screenH * (1 - (swings ? 0.10 : 0.06) * press)
let s = handPts / (a.wrist - a.handTop)
let widthPts = s * a.aspect
let handTipPts = (a.wrist - a.tip) * s
Expand All @@ -154,17 +241,18 @@ final class ArmView: NSView {
var rodLen = along - handTipPts
rodLen = max(minRodFrac * screenH, rodLen)
let tipAngle = atan2(rodLen + handTipPts, tipOffX)
let rotation = aimAngle - tipAngle
let rotation = aimAngle - tipAngle + sw.tilt

let lift = currentLift(screenH)
let lift = liftFrac * screenH * (1 - 0.8 * press)
let pressed = max(0, press)
let sx = lift * 0.6
let sy = -lift
let shadowOffset = CGSize(
width: sx * cos(-rotation) - sy * sin(-rotation),
height: sx * sin(-rotation) + sy * cos(-rotation)
)

let pose = [rotation, rodLen, handPts, widthPts, grip.x, lift, pressAmount, showShadow ? 1 : 0, a.aspect]
let pose = [rotation, rodLen, handPts, widthPts, grip.x, grip.y, lift, press, showShadow ? 1 : 0, a.aspect]
.map { ($0 * 100).rounded() }
guard pose != lastPose else {
return
Expand All @@ -176,8 +264,8 @@ final class ArmView: NSView {

for sub in [rodLayer, handLayer] {
sub.shadowOffset = shadowOffset
sub.shadowRadius = max(1, lift * 0.35) + 5 * pressAmount
sub.shadowOpacity = showShadow ? Float(0.26 + 0.02 * pressAmount) : 0
sub.shadowRadius = max(1, lift * 0.35) + 5 * pressed
sub.shadowOpacity = showShadow ? Float(0.26 + 0.02 * pressed) : 0
}

rodLayer.bounds = CGRect(x: 0, y: 0, width: widthPts, height: rodLen)
Expand All @@ -192,10 +280,6 @@ final class ArmView: NSView {
CATransaction.commit()
}

private func currentLift(_ screenH: CGFloat) -> CGFloat {
liftFrac * screenH * (1 - 0.8 * pressAmount)
}

private func aimLaser(cursor: CGPoint) {
let press = pressAmount
let r = max(4, bounds.height * 0.0038 * handLenFrac / 0.13) * (1 + 0.2 * press)
Expand Down
10 changes: 9 additions & 1 deletion Sources/TinyHand/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,15 @@ final class OverlayController {
}
self.armView.press()
self.armView.held = true
self.tapSound.play()
let delay = self.armView.tapSoundDelay
if delay == 0 {
self.tapSound.play()
} else {
// The hand swings up first; play the sound when the fingertip lands.
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.tapSound.play()
}
}
self.toolbar.hideHint()
self.downPoint = self.localMouse()
self.dragEvents = 0
Expand Down
11 changes: 10 additions & 1 deletion Sources/TinyHand/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ enum Preferences {
set { set(Double(newValue), "pointerSize") }
}

static let swingRangeLimits: ClosedRange<Double> = 0...2

/// How far the stick swings up on a tap. 0 keeps the original tap (the hand only shrinks a little);
/// 1 lifts the fingertip about 20% of the screen height.
static var swingRange: Double {
get { defaults.object(forKey: "swingRange") as? Double ?? 1 }
set { set(min(max(newValue, swingRangeLimits.lowerBound), swingRangeLimits.upperBound), "swingRange") }
}

static var showShadow: Bool {
get { defaults.object(forKey: "showShadow") as? Bool ?? true }
set { set(newValue, "showShadow") }
Expand Down Expand Up @@ -187,7 +196,7 @@ enum Preferences {
static func resetAll() {
for key in ["shortcut.toggle", "shortcut.clear", "pointerSize", "showShadow", "fadeSeconds",
"sound.hands", "sound.laser", "interactKey", "interactKeepsHand", "doubleShiftClickThrough", "pinnedStyles", "pointerStyle",
"inkChoice", "showInkButton", "showToolbar", "tapSound"] {
"inkChoice", "showInkButton", "showToolbar", "tapSound", "swingRange"] {
defaults.removeObject(forKey: key)
}
NotificationCenter.default.post(name: changed, object: nil)
Expand Down
1 change: 1 addition & 0 deletions Sources/TinyHand/RenderTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum RenderTest {
if !resting {
view.press()
view.held = true
view.skipSwing()
}
let center = CGPoint(x: target.x + 40, y: target.y - 8)
let a0 = atan2(target.y - center.y, target.x - center.x)
Expand Down
Loading