From b21dc52a6d6646f9608f81bea1e4c271bb1f3cff Mon Sep 17 00:00:00 2001 From: tumaranja <42451460+tumaranja@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:33:01 +0200 Subject: [PATCH 1/2] Swing range: a more expressive tap On a tap the stick swings up and back down onto the spot, the holding hand rising with it, and the tap sound plays when the fingertip lands. A new Swing range button in the toolbar opens a slider from Off to 2x (default 1x); Off keeps today's tap exactly. Co-Authored-By: Claude Opus 5.5 --- Sources/TinyHand/ArmView.swift | 116 ++++++++++++-- Sources/TinyHand/OverlayController.swift | 10 +- Sources/TinyHand/Preferences.swift | 11 +- Sources/TinyHand/RenderTest.swift | 1 + Sources/TinyHand/Toolbar.swift | 186 ++++++++++++++++++++++- Sources/TinyHand/main.swift | 5 + 6 files changed, 310 insertions(+), 19 deletions(-) diff --git a/Sources/TinyHand/ArmView.swift b/Sources/TinyHand/ArmView.swift index 7de9cee..2c54255 100644 --- a/Sources/TinyHand/ArmView.swift +++ b/Sources/TinyHand/ArmView.swift @@ -19,10 +19,26 @@ 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] = [] @@ -30,7 +46,7 @@ final class ArmView: NSView { 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 { @@ -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) } } @@ -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) { @@ -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 @@ -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 @@ -154,9 +241,10 @@ 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( @@ -164,7 +252,7 @@ final class ArmView: NSView { 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 @@ -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) @@ -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) diff --git a/Sources/TinyHand/OverlayController.swift b/Sources/TinyHand/OverlayController.swift index b0eae63..49b10a4 100644 --- a/Sources/TinyHand/OverlayController.swift +++ b/Sources/TinyHand/OverlayController.swift @@ -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 diff --git a/Sources/TinyHand/Preferences.swift b/Sources/TinyHand/Preferences.swift index 6eff8f7..452c21d 100644 --- a/Sources/TinyHand/Preferences.swift +++ b/Sources/TinyHand/Preferences.swift @@ -99,6 +99,15 @@ enum Preferences { set { set(Double(newValue), "pointerSize") } } + static let swingRangeLimits: ClosedRange = 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") } @@ -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) diff --git a/Sources/TinyHand/RenderTest.swift b/Sources/TinyHand/RenderTest.swift index fa53aed..cca8324 100644 --- a/Sources/TinyHand/RenderTest.swift +++ b/Sources/TinyHand/RenderTest.swift @@ -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) diff --git a/Sources/TinyHand/Toolbar.swift b/Sources/TinyHand/Toolbar.swift index 95086a0..dbbe4a1 100644 --- a/Sources/TinyHand/Toolbar.swift +++ b/Sources/TinyHand/Toolbar.swift @@ -15,6 +15,54 @@ final class Toolbar { override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } } + private final class Slider: NSSlider { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + } + + /// Slider drawn in the toolbar's own look: a thin white track on the dark glass and a plain white knob. + private final class SliderCell: NSSliderCell { + /// The knob's centre runs from one end of the rail to the other, so at Off and at the top + /// the knob sits right over the rail's end and no rail shows beyond it. + private static let inset: CGFloat = 8 + + private var fraction: CGFloat { + let span = maxValue - minValue + return span > 0 ? CGFloat((doubleValue - minValue) / span) : 0 + } + + override func drawBar(inside rect: NSRect, flipped: Bool) { + let i = SliderCell.inset + let bar = NSRect(x: rect.minX + i, y: rect.midY - 2, width: rect.width - 2 * i, height: 4) + NSColor(white: 1, alpha: 0.18).setFill() + NSBezierPath(roundedRect: bar, xRadius: 2, yRadius: 2).fill() + var filled = bar + filled.size.width = fraction * bar.width + NSColor(white: 1, alpha: 0.85).setFill() + NSBezierPath(roundedRect: filled, xRadius: 2, yRadius: 2).fill() + } + + override func knobRect(flipped: Bool) -> NSRect { + let bar = barRect(flipped: flipped) + let i = SliderCell.inset + let cx = bar.minX + i + fraction * (bar.width - 2 * i) + return NSRect(x: cx - 10, y: bar.midY - 10, width: 20, height: 20) + } + + override func drawKnob(_ knobRect: NSRect) { + let d: CGFloat = 14 + let dot = NSRect(x: knobRect.midX - d / 2, y: knobRect.midY - d / 2, width: d, height: d) + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor(white: 0, alpha: 0.4) + shadow.shadowBlurRadius = 3 + shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.set() + NSColor.white.setFill() + NSBezierPath(ovalIn: dot).fill() + NSGraphicsContext.restoreGraphicsState() + } + } + private let panel: Panel private let stack = NSStackView() private var styleButtons: [PointerStyle: Button] = [:] @@ -25,13 +73,24 @@ final class Toolbar { private var inkOpen = false private let permissionButton = Button() private var soundButton: Button? + private var rangeButton: Button? + private var rangeTray: Panel? + private var rangeSlider: Slider? + private var rangeLabel: NSTextField? + private var rangeOpen = false private(set) var revealed = false private let tray: Panel private let trayStack = NSStackView() private var hint: Panel? - var frame: CGRect { inkOpen ? panel.frame.union(tray.frame) : panel.frame } + var frame: CGRect { + var f = inkOpen ? panel.frame.union(tray.frame) : panel.frame + if rangeOpen, let t = rangeTray { + f = f.union(t.frame) + } + return f + } init() { panel = Panel(contentRect: CGRect(x: 0, y: 0, width: 300, height: 56), @@ -92,6 +151,11 @@ final class Toolbar { } inkButton = ink stack.addArrangedSubview(ink) + let rangeButtonView = makeButton(symbol: "hand.tap", tip: "Swing range") { [weak self] in + self?.setSwingRangeOpen(!(self?.rangeOpen ?? true)) + } + rangeButton = rangeButtonView + stack.addArrangedSubview(rangeButtonView) let sound = makeButton(symbol: "speaker.wave.2.fill", tip: "Tap sound") { [weak self] in self?.onToggleSound?() } @@ -127,6 +191,7 @@ final class Toolbar { NotificationCenter.default.addObserver(forName: Preferences.changed, object: nil, queue: .main) { [weak self] _ in self?.rebuildStyles() self?.refreshInk() + self?.refreshSwingRange() } } @@ -134,12 +199,115 @@ final class Toolbar { setInkOpen(!inkOpen) } + /// A small tray above the toolbar with a slider for how far the stick swings up on a tap. + private func setSwingRangeOpen(_ open: Bool) { + guard open != rangeOpen, let button = rangeButton else { + return + } + rangeOpen = open + button.layer?.backgroundColor = open ? NSColor(white: 1, alpha: 0.18).cgColor : NSColor.clear.cgColor + let t = rangeTray ?? makeSwingRangeTray() + if open { + setInkOpen(false) + refreshSwingRange() + let r = panel.convertToScreen(button.convert(button.bounds, to: nil)) + let size = t.frame.size + t.setFrameOrigin(CGPoint(x: r.midX - size.width / 2, y: panel.frame.maxY + 8)) + t.invalidateShadow() + t.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.12 + t.animator().alphaValue = 1 + } + } else { + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.12 + t.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + guard let self = self, !self.rangeOpen else { + return + } + t.orderOut(nil) + }) + } + } + + private func makeSwingRangeTray() -> Panel { + let t = Panel(contentRect: CGRect(x: 0, y: 0, width: 260, height: 40), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + t.isOpaque = false + t.backgroundColor = .clear + t.hasShadow = true + t.hidesOnDeactivate = false + t.isFloatingPanel = true + t.becomesKeyOnlyIfNeeded = true + t.level = panel.level + t.collectionBehavior = panel.collectionBehavior + t.alphaValue = 0 + let background = NSVisualEffectView.darkGlass(radius: 14) + t.contentView = background + + let slider = Slider() + slider.cell = SliderCell() + slider.minValue = Preferences.swingRangeLimits.lowerBound + slider.maxValue = Preferences.swingRangeLimits.upperBound + slider.doubleValue = Preferences.swingRange + slider.target = self + slider.action = #selector(swingRangeChanged(_:)) + slider.isContinuous = true + slider.toolTip = "Swing range" + slider.setAccessibilityLabel("Swing range") + slider.widthAnchor.constraint(equalToConstant: 140).isActive = true + slider.heightAnchor.constraint(equalToConstant: 30).isActive = true + let value = NSTextField(labelWithString: "1.0×") + value.font = .monospacedDigitSystemFont(ofSize: 12, weight: .medium) + value.textColor = NSColor(white: 1, alpha: 0.85) + value.alignment = .right + value.widthAnchor.constraint(equalToConstant: 34).isActive = true + + let row = NSStackView(views: [slider, value]) + row.orientation = .horizontal + row.spacing = 10 + row.edgeInsets = NSEdgeInsets(top: 5, left: 12, bottom: 5, right: 12) + row.translatesAutoresizingMaskIntoConstraints = false + background.addSubview(row) + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: background.leadingAnchor), + row.trailingAnchor.constraint(equalTo: background.trailingAnchor), + row.topAnchor.constraint(equalTo: background.topAnchor), + row.bottomAnchor.constraint(equalTo: background.bottomAnchor), + ]) + background.removeFocusRings() + row.layoutSubtreeIfNeeded() + t.setContentSize(row.fittingSize) + + rangeTray = t + rangeSlider = slider + rangeLabel = value + return t + } + + @objc private func swingRangeChanged(_ sender: NSSlider) { + let rounded = (sender.doubleValue * 20).rounded() / 20 + Preferences.swingRange = rounded + refreshSwingRange() + } + + private func refreshSwingRange() { + let k = Preferences.swingRange + if let slider = rangeSlider, abs(slider.doubleValue - k) > 0.05 { + slider.doubleValue = k + } + rangeLabel?.stringValue = k < 0.025 ? "Off" : String(format: "%.1f×", k) + } + private func setInkOpen(_ open: Bool) { guard open != inkOpen, let ink = inkButton else { return } inkOpen = open if open { + setSwingRangeOpen(false) if swatches.isEmpty { for choice in InkChoice.allCases { let b = makeButton(image: nil, tip: choice.title, side: 30) { [weak self] in @@ -263,6 +431,20 @@ final class Toolbar { return rep } + func snapshotSwingRangeTray() -> NSBitmapImageRep? { + setSwingRangeOpen(true) + guard let view = rangeTray?.contentView else { + return nil + } + view.layer?.backgroundColor = NSColor(white: 0.16, alpha: 1).cgColor + view.layoutSubtreeIfNeeded() + guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { + return nil + } + view.cacheDisplay(in: view.bounds, to: rep) + return rep + } + func snapshotInkTray() -> NSBitmapImageRep? { setInkOpen(true) guard let view = tray.contentView else { @@ -386,6 +568,7 @@ final class Toolbar { func hide() { setInkOpen(false) + setSwingRangeOpen(false) hideHint() revealed = false panel.alphaValue = 0 @@ -399,6 +582,7 @@ final class Toolbar { revealed = value if !value { setInkOpen(false) + setSwingRangeOpen(false) } panel.ignoresMouseEvents = !value NSAnimationContext.runAnimationGroup { ctx in diff --git a/Sources/TinyHand/main.swift b/Sources/TinyHand/main.swift index 92f758e..a449d31 100644 --- a/Sources/TinyHand/main.swift +++ b/Sources/TinyHand/main.swift @@ -35,6 +35,11 @@ if let idx = CommandLine.arguments.firstIndex(of: "--toolbar-test") { print("rendered \(hintOut)") } } + if CommandLine.arguments.contains("--swing-range"), let data = bar.snapshotSwingRangeTray()?.representation(using: .png, properties: [:]) { + let trayOut = out.replacingOccurrences(of: ".png", with: "-range.png") + try? data.write(to: URL(fileURLWithPath: trayOut)) + print("rendered \(trayOut)") + } if CommandLine.arguments.contains("--ink"), let data = bar.snapshotInkTray()?.representation(using: .png, properties: [:]) { let trayOut = out.replacingOccurrences(of: ".png", with: "-ink.png") try? data.write(to: URL(fileURLWithPath: trayOut)) From 3c89a2c6a711ec2e2ed627768dfadb6db76d4e6c Mon Sep 17 00:00:00 2001 From: tumaranja <42451460+tumaranja@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:33:19 +0200 Subject: [PATCH 2/2] README: mention Swing range in the toolbar Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc0a8c3..0c69fd7 100644 --- a/README.md +++ b/README.md @@ -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