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
116 changes: 115 additions & 1 deletion Sources/TinyHand/Ink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,42 @@ import AppKit

final class Ink {
let layer = CALayer()
var keep: Bool { Preferences.fadeSeconds <= 0 }
var keep: Bool { Preferences.fadeSeconds == 0 }

// With the trail setting, an eraser follows each line from its start at a steady speed, so
// long lines take longer to go than short ones. It starts `trailDelay` after the line begins
// and never erases what was drawn within that time.
private var trailing: Bool { Preferences.fadeSeconds < 0 }
private var trailDelay: CFTimeInterval { Preferences.trailDelay }
private var eraseSpeed: CGFloat { CGFloat(Preferences.trailSpeed) }

private final class Trail {
let layer: CAShapeLayer
let start: CFTimeInterval
var last: CGPoint
var length: CGFloat = 0
var times: [CFTimeInterval] = []
var distances: [CGFloat] = []
var erased: CGFloat = 0

init(layer: CAShapeLayer, at point: CGPoint, time: CFTimeInterval) {
self.layer = layer
start = time
last = point
times = [time]
distances = [0]
}

func add(_ point: CGPoint, at time: CFTimeInterval) {
length += hypot(point.x - last.x, point.y - last.y)
last = point
times.append(time)
distances.append(length)
}
}
private var live: Trail?
private var trailTimer: Timer?
private var lastStep: CFTimeInterval = 0

static let purple = CGColor(srgbRed: 0.53, green: 0.33, blue: 0.95, alpha: 1)
static let red = CGColor(srgbRed: 1.0, green: 0.16, blue: 0.12, alpha: 1)
Expand Down Expand Up @@ -47,6 +82,10 @@ final class Ink {
layer.addSublayer(s)
stroke = s
points = [point]
if trailing {
live = Trail(layer: s, at: point, time: CACurrentMediaTime())
startTrailTimer()
}
redraw()
}

Expand All @@ -58,6 +97,7 @@ final class Ink {
return
}
points.append(point)
live?.add(point, at: CACurrentMediaTime())
redraw()
}

Expand All @@ -66,8 +106,15 @@ final class Ink {
return
}
stroke = nil
let headLength = self.headLength(s)
points = []
arrow = false
if let t = live {
live = nil
stopTrailTimer()
finish(t, headLength: headLength)
return
}
freeze(s)
if keep {
return
Expand All @@ -78,9 +125,76 @@ final class Ink {
func clear() {
stroke = nil
points = []
live = nil
stopTrailTimer()
layer.sublayers?.forEach { $0.removeFromSuperlayer() }
}

private func startTrailTimer() {
stopTrailTimer()
lastStep = CACurrentMediaTime()
let timer = Timer(timeInterval: 1.0 / 120, repeats: true) { [weak self] _ in
self?.stepLiveTrail()
}
RunLoop.main.add(timer, forMode: .common)
trailTimer = timer
}

private func stopTrailTimer() {
trailTimer?.invalidate()
trailTimer = nil
}

// Moves the eraser along the line being drawn, keeping clear of the part drawn most recently.
private func stepLiveTrail() {
let now = CACurrentMediaTime()
let step = eraseSpeed * CGFloat(now - lastStep)
lastStep = now
guard let t = live, now - t.start >= trailDelay else {
return
}
let limit = t.times.lastIndex { $0 <= now - trailDelay }.map { t.distances[$0] } ?? 0
t.erased = min(t.erased + step, max(t.erased, limit))
setStrokeStart(t)
}

private func setStrokeStart(_ t: Trail) {
let total = t.length + headLength(t.layer)
CATransaction.begin()
CATransaction.setDisableActions(true)
t.layer.strokeStart = total > 0 ? min(1, t.erased / total) : 0
CATransaction.commit()
}

// Once the line is finished, the rest of the erasing runs as one animation at the same speed.
private func finish(_ t: Trail, headLength: CGFloat) {
let now = CACurrentMediaTime()
let wait = max(0, t.start + trailDelay - now)
let total = t.length + headLength
let from = total > 0 ? min(1, t.erased / total) : 0
let duration = max(0.05, CFTimeInterval((total - t.erased) / eraseSpeed))
CATransaction.begin()
CATransaction.setDisableActions(true)
t.layer.strokeStart = 1
CATransaction.commit()
let anim = CABasicAnimation(keyPath: "strokeStart")
anim.fromValue = from
anim.toValue = 1
anim.beginTime = now + wait
anim.duration = duration
anim.timingFunction = CAMediaTimingFunction(name: .linear)
anim.fillMode = .backwards
t.layer.add(anim, forKey: "trail")
DispatchQueue.main.asyncAfter(deadline: .now() + wait + duration) { [weak s = t.layer] in
s?.removeFromSuperlayer()
}
}

// The arrowhead's two strokes are drawn after the line, so the eraser reaches them last.
private func headLength(_ s: CAShapeLayer) -> CGFloat {
arrow ? 2 * max(14, s.lineWidth * 3.2) : 0
}

private func freeze(_ s: CAShapeLayer) {
guard let path = s.path else {
return
Expand Down
19 changes: 18 additions & 1 deletion Sources/TinyHand/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,23 @@ enum Preferences {
set { set(newValue, "fadeSeconds") }
}

/// The `fadeSeconds` value that makes lines trail the pen instead of fading as a whole.
static let trailFade: Double = -1

static let trailDelayRange: ClosedRange<Double> = 0.2...2

/// Seconds from the start of a line until the trail starts erasing it.
static var trailDelay: Double {
get { defaults.object(forKey: "trailDelay") as? Double ?? 0.6 }
set { set(newValue, "trailDelay") }
}

/// How fast the trail erases, in points per second.
static var trailSpeed: Double {
get { defaults.object(forKey: "trailSpeed") as? Double ?? 2000 }
set { set(newValue, "trailSpeed") }
}

static var handSound: Bool {
get { defaults.object(forKey: "sound.hands") as? Bool ?? true }
set { set(newValue, "sound.hands") }
Expand Down Expand Up @@ -185,7 +202,7 @@ enum Preferences {
}

static func resetAll() {
for key in ["shortcut.toggle", "shortcut.clear", "pointerSize", "showShadow", "fadeSeconds",
for key in ["shortcut.toggle", "shortcut.clear", "pointerSize", "showShadow", "fadeSeconds", "trailDelay", "trailSpeed",
"sound.hands", "sound.laser", "interactKey", "interactKeepsHand", "doubleShiftClickThrough", "pinnedStyles", "pointerStyle",
"inkChoice", "showInkButton", "showToolbar", "tapSound"] {
defaults.removeObject(forKey: key)
Expand Down
36 changes: 36 additions & 0 deletions Sources/TinyHand/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,13 @@ private struct DrawingPage: View {
("After 6 seconds", 6),
("After 10 seconds", 10),
("Keep until cleared", 0),
("Trail behind the pen", Preferences.trailFade),
]
private static let trailSpeeds: [(String, Double)] = [
("Slow", 800),
("Medium", 1400),
("Fast", 2000),
("Very fast", 3000),
]

var body: some View {
Expand All @@ -1002,8 +1009,37 @@ private struct DrawingPage: View {
.fixedSize()
.focusable(false)
}
if Preferences.fadeSeconds < 0 {
Row("Erasing starts", detail: "Counted from the start of each line.") {
HStack(spacing: 10) {
Slider(value: model.binding({ Preferences.trailDelay }, { Preferences.trailDelay = ($0 * 10).rounded() / 10 }),
in: Preferences.trailDelayRange)
.frame(width: 180)
.focusable(false)
Text(String(format: "%.1f s", Preferences.trailDelay))
.font(.system(size: 12).monospacedDigit())
.foregroundColor(.secondary)
.frame(width: 36, alignment: .trailing)
}
}
Row("Erasing speed", detail: "Long lines take longer to erase than short ones.") {
menu(DrawingPage.trailSpeeds, model.binding({ Preferences.trailSpeed }, { Preferences.trailSpeed = $0 }))
}
}
}
}

private func menu(_ choices: [(String, Double)], _ selection: Binding<Double>) -> some View {
Picker("", selection: selection) {
ForEach(choices, id: \.1) { choice in
Text(choice.0).tag(choice.1)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.focusable(false)
}
}

private struct SoundPage: View {
Expand Down