diff --git a/Sources/TinyHand/ArmView.swift b/Sources/TinyHand/ArmView.swift index 7de9cee..9baf5ca 100644 --- a/Sources/TinyHand/ArmView.swift +++ b/Sources/TinyHand/ArmView.swift @@ -19,8 +19,9 @@ final class ArmView: NSView { private let liftFrac: CGFloat = 0.06 let ink = Ink() + let ring = InkRing() var held = false - var inkWidth: CGFloat { max(3, bounds.height * 0.0055) } + var inkWidth: CGFloat { max(2, max(3, bounds.height * 0.0055) * Preferences.penSize.scale) } private var pressAmount: CGFloat = 0 private var gripX: CGFloat? @@ -56,6 +57,7 @@ final class ArmView: NSView { layer = root wantsLayer = true root.addSublayer(ink.layer) + root.addSublayer(ring.layer) root.addSublayer(group) root.addSublayer(dotLayer) diff --git a/Sources/TinyHand/Ink.swift b/Sources/TinyHand/Ink.swift index 766dbed..6bbbee6 100644 --- a/Sources/TinyHand/Ink.swift +++ b/Sources/TinyHand/Ink.swift @@ -75,6 +75,13 @@ final class Ink { fade(s, after: fadeDelay, over: fadeDuration) } + /// Fades out every finished drawing on screen that isn't already fading. + func fadeAll() { + for case let s as CAShapeLayer in layer.sublayers ?? [] where s !== stroke && s.animation(forKey: "fade") == nil { + fade(s, after: 0, over: fadeDuration) + } + } + func clear() { stroke = nil points = [] diff --git a/Sources/TinyHand/InkRing.swift b/Sources/TinyHand/InkRing.swift new file mode 100644 index 0000000..6f728cf --- /dev/null +++ b/Sources/TinyHand/InkRing.swift @@ -0,0 +1,384 @@ +import AppKit + +/// Quick ring: hold the right button and click left to open it at the pointer. +/// Left half: six ink colours. Right half: up to six pointers (your toolbar favourites, +/// always including the one in use). Middle: how long drawings last; each click cycles +/// Stay → 1 s → 3 s → 6 s. +/// Inside the colours, three subtle circles set the pen size: thick, medium, thin. +/// The label under the ring names whatever you hover. +final class InkRing { + enum Target: Equatable { + case color(InkChoice) + case pointer(PointerStyle) + case size(PenSize) + case timing + } + + static let colors: [InkChoice] = [.red, .yellow, .green, .blue, .purple, .white] + static let maxPointers = 6 + + private static let outer: CGFloat = 88 // disk radius + private static let orbit: CGFloat = 64 // distance of the items from the centre + private static let dotRadius: CGFloat = 12 + private static let slotRadius: CGFloat = 14 + private static let inner: CGFloat = 21 // Fade/Keep button radius + private static let sizeOrbit: CGFloat = 38 // pen-size circles sit between the centre button and the colours + private static let sizeBand: CGFloat = 51 // closer in than this on the left half = pen size + /// Thick near red, medium between green and blue, thin near white. + private static let sizes: [(size: PenSize, degrees: CGFloat, radius: CGFloat)] = [ + (.thick, 118, 9), (.medium, 180, 6), (.thin, 242, 3.5), + ] + private static let labelHeight: CGFloat = 20 + private static let labelGap: CGFloat = 8 + + let layer = CALayer() + private let disk = CAShapeLayer() + private var dots: [CAShapeLayer] = [] + private var sizeDots: [CAShapeLayer] = [] + private var slots: [(style: PointerStyle, back: CAShapeLayer, image: CALayer)] = [] + private let middle = CAShapeLayer() + private let icon = CALayer() + private let label = CATextLayer() + + private(set) var isOpen = false + private var origin: CGPoint = .zero + /// Where the pointer was when the ring opened (the ring itself may sit elsewhere near a screen edge). + private(set) var openedAt: CGPoint = .zero + private var hovered: Target? + private var fade: Double = 0 // seconds; 0 = drawings stay + private let centreText = CATextLayer() + private var targets: [(target: Target, angle: CGFloat)] = [] + + private var centre: CGPoint { CGPoint(x: InkRing.outer, y: InkRing.outer) } + + init() { + let o = InkRing.outer + layer.bounds = CGRect(x: 0, y: 0, width: o * 2, height: o * 2) + layer.isHidden = true + layer.masksToBounds = false + + disk.frame = layer.bounds + disk.path = CGPath(ellipseIn: layer.bounds, transform: nil) + disk.fillColor = CGColor(gray: 0.08, alpha: 0.82) + disk.strokeColor = CGColor(gray: 1, alpha: 0.18) + disk.lineWidth = 1 + disk.shadowColor = NSColor.black.cgColor + disk.shadowOpacity = 0.35 + disk.shadowRadius = 8 + disk.shadowOffset = CGSize(width: 0, height: -2) + layer.addSublayer(disk) + + // A faint hairline down the middle separates colours from pointers. + let divider = CAShapeLayer() + let path = CGMutablePath() + path.move(to: CGPoint(x: o, y: o + InkRing.inner + 8)) + path.addLine(to: CGPoint(x: o, y: o * 2 - 10)) + path.move(to: CGPoint(x: o, y: o - InkRing.inner - 8)) + path.addLine(to: CGPoint(x: o, y: 10)) + divider.path = path + divider.strokeColor = CGColor(gray: 1, alpha: 0.12) + divider.lineWidth = 1 + layer.addSublayer(divider) + + for (i, choice) in InkRing.colors.enumerated() { + let r = InkRing.dotRadius + let dot = CAShapeLayer() + dot.bounds = CGRect(x: 0, y: 0, width: r * 2, height: r * 2) + dot.position = point(at: InkRing.colorAngle(i)) + dot.path = CGPath(ellipseIn: dot.bounds, transform: nil) + dot.fillColor = choice.color + layer.addSublayer(dot) + dots.append(dot) + } + + for item in InkRing.sizes { + let r = item.radius + let dot = CAShapeLayer() + dot.bounds = CGRect(x: 0, y: 0, width: r * 2, height: r * 2) + dot.position = point(at: item.degrees * .pi / 180, orbit: InkRing.sizeOrbit) + dot.path = CGPath(ellipseIn: dot.bounds, transform: nil) + layer.addSublayer(dot) + sizeDots.append(dot) + } + + let m = InkRing.inner + middle.bounds = CGRect(x: 0, y: 0, width: m * 2, height: m * 2) + middle.position = centre + middle.path = CGPath(ellipseIn: middle.bounds, transform: nil) + middle.fillColor = CGColor(gray: 1, alpha: 0.12) + middle.strokeColor = CGColor(gray: 1, alpha: 0.3) + middle.lineWidth = 1 + layer.addSublayer(middle) + + icon.bounds = CGRect(x: 0, y: 0, width: 20, height: 20) + icon.position = centre + icon.contentsGravity = .resizeAspect + icon.contentsScale = 2 + layer.addSublayer(icon) + + centreText.alignmentMode = .center + centreText.fontSize = 12 + centreText.font = NSFont.systemFont(ofSize: 12, weight: .bold) + centreText.foregroundColor = NSColor.white.cgColor + centreText.contentsScale = 2 + centreText.bounds = CGRect(x: 0, y: 0, width: 34, height: 16) + centreText.position = centre + layer.addSublayer(centreText) + + label.alignmentMode = .center + label.fontSize = 11 + label.font = NSFont.systemFont(ofSize: 11, weight: .semibold) + label.foregroundColor = NSColor.white.cgColor + label.backgroundColor = CGColor(gray: 0.08, alpha: 0.82) + label.cornerRadius = InkRing.labelHeight / 2 + label.contentsScale = 2 + label.bounds = CGRect(x: 0, y: 0, width: 140, height: InkRing.labelHeight) + label.position = CGPoint(x: o, y: -InkRing.labelGap - InkRing.labelHeight / 2) + layer.addSublayer(label) + } + + // Angles in radians, 0 = right, counter-clockwise. Colours fill the left half from the top down, + // pointers fill the right half from the top down, leaving a gap at the top and bottom. + private static func colorAngle(_ i: Int) -> CGFloat { + (90 + 20 + 28 * CGFloat(i)) * .pi / 180 + } + + private static func pointerAngle(_ i: Int) -> CGFloat { + (90 - 20 - 28 * CGFloat(i)) * .pi / 180 + } + + private func point(at angle: CGFloat, orbit: CGFloat = InkRing.orbit) -> CGPoint { + CGPoint(x: centre.x + cos(angle) * orbit, y: centre.y + sin(angle) * orbit) + } + + /// What the centre button cycles through. 0 = drawings stay until cleared. + static let fadeCycle: [Double] = [0, 1, 3, 6] + + /// The next fade time after this one. A value from Settings that isn't in the cycle (10 s) + /// moves to the next one up, or back to Stay. + static func nextFade(after current: Double) -> Double { + if let i = fadeCycle.firstIndex(of: current) { + return fadeCycle[(i + 1) % fadeCycle.count] + } + return fadeCycle.first { $0 > current } ?? 0 + } + + static func describe(_ seconds: Double) -> String { + seconds <= 0 ? "Drawings stay" : "Drawings fade after \(Int(seconds)) s" + } + + /// The pointers to offer: toolbar favourites in order, always including the current one. + static func pointers(current: PointerStyle) -> [PointerStyle] { + var list = Array(Preferences.pinnedStyles.prefix(maxPointers)) + if !list.contains(current) { + if list.count == maxPointers { + list.removeLast() + } + list.append(current) + } + return list + } + + func open(at point: CGPoint, in bounds: CGRect, selected: InkChoice, fade: Double, pointer: PointerStyle, pen: PenSize) { + let o = InkRing.outer + let below = InkRing.labelGap + InkRing.labelHeight + let x = min(max(point.x, bounds.minX + o + 8), bounds.maxX - o - 8) + let y = min(max(point.y, bounds.minY + o + below + 8), bounds.maxY - o - 8) + origin = CGPoint(x: x, y: y) + openedAt = point + hovered = nil + + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.position = origin + layer.isHidden = false + buildPointerSlots(InkRing.pointers(current: pointer)) + CATransaction.commit() + + update(selected: selected, fade: fade, pointer: pointer, pen: pen) + applyHover() + + let pop = CABasicAnimation(keyPath: "transform.scale") + pop.fromValue = 0.85 + pop.toValue = 1 + let show = CABasicAnimation(keyPath: "opacity") + show.fromValue = 0 + show.toValue = 1 + let anim = CAAnimationGroup() + anim.animations = [pop, show] + anim.duration = 0.12 + anim.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer.add(anim, forKey: "open") + isOpen = true + } + + private func buildPointerSlots(_ styles: [PointerStyle]) { + for s in slots { + s.back.removeFromSuperlayer() + s.image.removeFromSuperlayer() + } + slots = [] + targets = InkRing.colors.enumerated().map { (target: Target.color($0.element), angle: InkRing.colorAngle($0.offset)) } + + let r = InkRing.slotRadius + for (i, style) in styles.enumerated() { + let angle = InkRing.pointerAngle(i) + let back = CAShapeLayer() + back.bounds = CGRect(x: 0, y: 0, width: r * 2, height: r * 2) + back.position = point(at: angle) + back.path = CGPath(ellipseIn: back.bounds, transform: nil) + back.fillColor = CGColor(gray: 1, alpha: 0.10) + layer.insertSublayer(back, below: middle) + + let image = CALayer() + image.bounds = CGRect(x: 0, y: 0, width: r * 1.6, height: r * 1.6) + image.position = back.position + image.contentsGravity = .resizeAspect + image.contentsScale = 2 + image.contents = Toolbar.icon(for: style) + layer.insertSublayer(image, below: middle) + + slots.append((style: style, back: back, image: image)) + targets.append((target: .pointer(style), angle: angle)) + } + } + + func close() { + guard isOpen else { + return + } + isOpen = false + hovered = nil + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.removeAllAnimations() + layer.isHidden = true + CATransaction.commit() + } + + func update(selected: InkChoice, fade: Double, pointer: PointerStyle, pen: PenSize) { + self.fade = fade + CATransaction.begin() + CATransaction.setDisableActions(true) + for (i, item) in InkRing.sizes.enumerated() { + let on = item.size == pen + sizeDots[i].fillColor = CGColor(gray: 1, alpha: on ? 0.95 : 0.35) + sizeDots[i].strokeColor = on ? CGColor(gray: 1, alpha: 0.5) : nil + sizeDots[i].lineWidth = on ? 4 : 0 + } + for (i, choice) in InkRing.colors.enumerated() { + dots[i].fillColor = choice.color // picks up the Ink tone setting + let on = choice == selected + dots[i].strokeColor = on ? NSColor.white.cgColor : CGColor(gray: 1, alpha: 0.35) + dots[i].lineWidth = on ? 3 : 1 + } + for s in slots { + let on = s.style == pointer + s.back.strokeColor = on ? NSColor.white.cgColor : CGColor(gray: 1, alpha: 0.22) + s.back.lineWidth = on ? 2.5 : 1 + } + // Stay shows a pin; a fade time shows the seconds. + let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold) + .applying(.init(paletteColors: [.white])) + icon.contents = NSImage(systemSymbolName: "pin.fill", accessibilityDescription: nil)?.withSymbolConfiguration(config) + icon.isHidden = fade > 0 + centreText.isHidden = fade <= 0 + centreText.string = "\(Int(fade))s" + CATransaction.commit() + updateLabel() + } + + func target(at point: CGPoint) -> Target? { + let dx = point.x - origin.x + let dy = point.y - origin.y + let r = hypot(dx, dy) + if r <= InkRing.inner + 4 { + return .timing + } + if r > InkRing.outer + 14 { + return nil + } + let a0 = atan2(dy, dx) + if dx < 0 && r < InkRing.sizeBand { + var bestSize = InkRing.sizes[0].size + var bestSizeGap = CGFloat.greatestFiniteMagnitude + for item in InkRing.sizes { + let gap = abs((a0 - item.degrees * .pi / 180).remainder(dividingBy: 2 * .pi)) + if gap < bestSizeGap { + bestSizeGap = gap + bestSize = item.size + } + } + return .size(bestSize) + } + // Nearest item by angle, so the whole slice around an item picks it. + let a = a0 + var best: Target? + var bestGap = CGFloat.greatestFiniteMagnitude + for t in targets { + let gap = abs((a - t.angle).remainder(dividingBy: 2 * .pi)) + if gap < bestGap { + bestGap = gap + best = t.target + } + } + return best + } + + func hover(_ point: CGPoint) { + let t = target(at: point) + guard t != hovered else { + return + } + hovered = t + applyHover() + } + + private func applyHover() { + CATransaction.begin() + CATransaction.setAnimationDuration(0.08) + for (i, choice) in InkRing.colors.enumerated() { + let big = hovered == .color(choice) + dots[i].transform = big ? CATransform3DMakeScale(1.3, 1.3, 1) : CATransform3DIdentity + } + for s in slots { + let big = hovered == .pointer(s.style) + let t = big ? CATransform3DMakeScale(1.25, 1.25, 1) : CATransform3DIdentity + s.back.transform = t + s.image.transform = t + } + for (i, item) in InkRing.sizes.enumerated() { + let big = hovered == .size(item.size) + sizeDots[i].transform = big ? CATransform3DMakeScale(1.4, 1.4, 1) : CATransform3DIdentity + } + let mid = hovered == .timing + middle.transform = mid ? CATransform3DMakeScale(1.15, 1.15, 1) : CATransform3DIdentity + middle.fillColor = CGColor(gray: 1, alpha: mid ? 0.25 : 0.12) + CATransaction.commit() + updateLabel() + } + + private func updateLabel() { + let text: String + switch hovered { + case .color(let choice)?: + text = choice.title + case .pointer(let style)?: + text = style.title + case .size(let size)?: + text = "\(size.title) pen" + case .timing?: + let next = InkRing.nextFade(after: fade) + text = next <= 0 ? "Click: drawings stay" : "Click: fade after \(Int(next)) s" + case nil: + text = InkRing.describe(fade) + } + let font = NSFont.systemFont(ofSize: 11, weight: .semibold) + let width = max(90, (text as NSString).size(withAttributes: [.font: font]).width + 24) + CATransaction.begin() + CATransaction.setDisableActions(true) + label.string = text + label.bounds = CGRect(x: 0, y: 0, width: width, height: InkRing.labelHeight) + CATransaction.commit() + } +} diff --git a/Sources/TinyHand/InputTap.swift b/Sources/TinyHand/InputTap.swift index f9f7971..81616fa 100644 --- a/Sources/TinyHand/InputTap.swift +++ b/Sources/TinyHand/InputTap.swift @@ -5,11 +5,33 @@ final class InputTap { var onInteractDown: (() -> Void)? var onInteractUp: (() -> Void)? var onMouseUp: (() -> Void)? - var onLeftDragged: (() -> Void)? + var onDrawDragged: (() -> Void)? + /// Real-click mode: a left click that went through to the app underneath. + var onLeftClick: (() -> Void)? var onPassThroughPress: ((CGPoint) -> Void)? var onScreenLocked: (() -> Void)? + /// Left pressed while the right button is held: open the quick ring. + var onChord: (() -> Void)? + /// Left pressed while the ring is open. + var onRingClick: (() -> Void)? + var onRightUp: (() -> Void)? private let lock = NSLock() + private var _ringOpen = false + private var _realClicks = false + + /// Real-click mode: the left button works as a normal mouse and the right button taps and draws. + var realClicks: Bool { + get { lock.withLock { _realClicks } } + set { lock.withLock { _realClicks = newValue } } + } + private var _leftCaptured = false + + /// While the quick ring is open, clicks pick from it. + var ringOpen: Bool { + get { lock.withLock { _ringOpen } } + set { lock.withLock { _ringOpen = newValue } } + } private var _active = false private var _passThroughRects: [CGRect] = [] private var _interactKey: CGEventFlags = [] @@ -105,6 +127,7 @@ final class InputTap { lock.withLock { _active = value _interactPress = false + _leftCaptured = false } guard let tap = tap else { return @@ -184,6 +207,27 @@ final class InputTap { return Unmanaged.passUnretained(event) } + switch handleRing(type: type, event: event) { + case .swallow: + return nil + case .pass: + return Unmanaged.passUnretained(event) + case .notRing: + break + } + + let real = realClicks + if real && (type == .leftMouseDown || type == .leftMouseUp || type == .leftMouseDragged) { + if type == .leftMouseDown { + if InputTap.screenLocked { + DispatchQueue.main.async { [weak self] in self?.onScreenLocked?() } + } else { + DispatchQueue.main.async { [weak self] in self?.onLeftClick?() } + } + } + return Unmanaged.passUnretained(event) + } + switch type { case .leftMouseDown, .rightMouseDown, .otherMouseDown: if InputTap.screenLocked { @@ -196,11 +240,16 @@ final class InputTap { if InputTap.screenLocked { return Unmanaged.passUnretained(event) } - DispatchQueue.main.async { [weak self] in self?.onMouseUp?() } + DispatchQueue.main.async { [weak self] in + self?.onMouseUp?() + if type == .rightMouseUp { + self?.onRightUp?() + } + } return nil case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - if type == .leftMouseDragged { - DispatchQueue.main.async { [weak self] in self?.onLeftDragged?() } + if type == (real ? .rightMouseDragged : .leftMouseDragged) { + DispatchQueue.main.async { [weak self] in self?.onDrawDragged?() } } event.type = .mouseMoved return Unmanaged.passUnretained(event) @@ -209,6 +258,49 @@ final class InputTap { } } + private enum RingResult { + case notRing, swallow, pass + } + + /// The quick ring: holding right and pressing left opens it; while it's open, left presses + /// pick from it and right presses don't tap. + private func handleRing(type: CGEventType, event: CGEvent) -> RingResult { + switch type { + case .leftMouseDown: + if InputTap.screenLocked { + return .notRing + } + if ringOpen { + lock.withLock { _leftCaptured = true } + DispatchQueue.main.async { [weak self] in self?.onRingClick?() } + return .swallow + } + if CGEventSource.buttonState(.hidSystemState, button: .right) { + lock.withLock { _leftCaptured = true } + DispatchQueue.main.async { [weak self] in self?.onChord?() } + return .swallow + } + return .notRing + case .leftMouseUp: + let captured = lock.withLock { () -> Bool in + let was = _leftCaptured + _leftCaptured = false + return was + } + return captured ? .swallow : .notRing + case .leftMouseDragged: + if lock.withLock({ _leftCaptured }) { + event.type = .mouseMoved + return .pass + } + return .notRing + case .rightMouseDown: + return ringOpen ? .swallow : .notRing + default: + return .notRing + } + } + // Backstop for lock paths that don't post com.apple.screenIsLocked. private static var screenLocked: Bool { guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else { diff --git a/Sources/TinyHand/OverlayController.swift b/Sources/TinyHand/OverlayController.swift index b0eae63..af18e31 100644 --- a/Sources/TinyHand/OverlayController.swift +++ b/Sources/TinyHand/OverlayController.swift @@ -11,6 +11,8 @@ final class OverlayController { private let armView: ArmView private let inputTap = InputTap() private let tapSound = TapSound() + // Real-click ripples sit just under the pointer's overlay window. + private let splash = Splash(level: NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 1)) private var interactHeld = false private var menuHover = false private var guardCursorUntil = Date.distantPast @@ -76,22 +78,59 @@ final class OverlayController { } self.armView.press() self.armView.held = true - self.tapSound.play() + if !self.inputTap.realClicks { + self.tapSound.play() // real-click mode plays it on release instead, so drawing stays silent + } self.toolbar.hideHint() self.downPoint = self.localMouse() self.dragEvents = 0 Log.write("press at \(self.downPoint.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")") self.noteButtonEvent() } - inputTap.onLeftDragged = { [weak self] in + inputTap.onDrawDragged = { [weak self] in self?.dragEvents += 1 self?.drag() self?.wake() } inputTap.onMouseUp = { [weak self] in - self?.release() + self?.release(tapSound: true) self?.noteButtonEvent() } + inputTap.onLeftClick = { [weak self] in + guard let self = self else { + return + } + if Preferences.rippleOn { + self.splash.splash(at: NSEvent.mouseLocation, look: self.rippleLook()) + } + self.tapSound.play() + self.noteButtonEvent() + } + NotificationCenter.default.addObserver(forName: Splash.previewRequested, object: nil, queue: .main) { [weak self] _ in + guard let self = self else { + return + } + self.splash.splash(at: NSEvent.mouseLocation, look: self.rippleLook()) + } + inputTap.onChord = { [weak self] in + self?.openRing() + } + inputTap.onRingClick = { [weak self] in + self?.ringPick(closeIfOutside: true) + } + // Letting go of the right button over a colour, pointer or pen size picks it, but only after + // moving toward one, so a quick hold-right-click-left doesn't pick whatever is under the pointer. + inputTap.onRightUp = { [weak self] in + guard let self = self, self.armView.ring.isOpen, let p = self.localMouse() else { + return + } + let start = self.armView.ring.openedAt + guard hypot(p.x - start.x, p.y - start.y) > 12, + let target = self.armView.ring.target(at: p), target != .timing else { + return + } + self.ringPick(closeIfOutside: false) + } inputTap.onInteractDown = { [weak self] in self?.armView.press() self?.armView.held = true @@ -137,6 +176,7 @@ final class OverlayController { NotificationCenter.default.addObserver(forName: Preferences.changed, object: nil, queue: .main) { [weak self] _ in self?.syncSound() self?.inputTap.interactKey = Preferences.interactKey.cgFlags + self?.inputTap.realClicks = Preferences.realClicks if let self = self, !Preferences.doubleShiftClickThrough, self.isClickThrough { self.setClickThrough(false) } @@ -328,7 +368,12 @@ final class OverlayController { deactivate() } - private func release() { + /// - Parameter playSound: in real-click mode, a right-click plays the tap sound when let go + /// without a drag. Drawing stays silent. + private func release(tapSound playSound: Bool = false) { + if playSound, inputTap.realClicks, downPoint != nil, !armView.ink.isDrawing { + tapSound.play() + } if let start = downPoint { let moved = localMouse().map { Int(hypot($0.x - start.x, $0.y - start.y)) } ?? -1 Log.write("release, drag events=\(dragEvents) moved=\(moved)pt stroke drawn=\(armView.ink.isDrawing)") @@ -357,6 +402,72 @@ final class OverlayController { "held=\(armView.held) downPoint=\(downPoint != nil) drawing=\(armView.ink.isDrawing) strokes=\(inkStrokes)" } + // MARK: Quick ring (hold right, click left) + + private func openRing() { + guard isActive, let p = localMouse() else { + return + } + release() + armView.ring.open(at: p, in: armView.bounds, selected: Preferences.inkChoice, + fade: Preferences.fadeSeconds, pointer: armView.style, pen: Preferences.penSize) + inputTap.ringOpen = true + Log.write("quick ring open") + wake() + } + + private func rippleLook() -> Splash.Look { + Splash.Look(style: Preferences.rippleStyle, + color: Preferences.rippleColor.color ?? armView.ink.color, + diameter: Preferences.rippleSize.diameter, + duration: Preferences.rippleSpeed.duration) + } + + private func refreshRing() { + armView.ring.update(selected: Preferences.inkChoice, fade: Preferences.fadeSeconds, + pointer: armView.style, pen: Preferences.penSize) + } + + private func closeRing() { + armView.ring.close() + inputTap.ringOpen = false + } + + private func ringPick(closeIfOutside: Bool) { + guard armView.ring.isOpen, let p = localMouse() else { + return + } + switch armView.ring.target(at: p) { + case .color(let choice)?: + Preferences.inkChoice = choice + closeRing() + case .pointer(let style)?: + pick(style) + closeRing() + case .size(let size)?: + // Stays open, so a colour can be picked next. + Preferences.penSize = size + refreshRing() + case .timing?: + cycleFade() + refreshRing() + case nil: + if closeIfOutside { + closeRing() + } + } + } + + /// Stay → 1 s → 3 s → 6 s → Stay. Leaving Stay fades everything already on screen. + private func cycleFade() { + let current = Preferences.fadeSeconds + let next = InkRing.nextFade(after: current) + Preferences.fadeSeconds = next + if current <= 0 && next > 0 { + armView.ink.fadeAll() + } + } + func clearInk() { armView.ink.clear() } @@ -423,6 +534,7 @@ final class OverlayController { options: [.userInitiated, .latencyCritical], reason: "TinyHand pointer is on") let installed = inputTap.install() inputTap.interactKey = Preferences.interactKey.cgFlags + inputTap.realClicks = Preferences.realClicks inputTap.clickThrough = false installShiftMonitors() inputTap.setActive(installed) @@ -436,7 +548,10 @@ final class OverlayController { revealUntil = Date().addingTimeInterval(3) if installed && !UserDefaults.standard.bool(forKey: OverlayController.hintShownKey) { UserDefaults.standard.set(true, forKey: OverlayController.hintShownKey) - var parts: [(key: String, text: String)] = [("Click", "to tap"), ("Drag", "to draw")] + var parts: [(key: String, text: String)] = Preferences.realClicks + ? [("Right-click", "to tap"), ("Right-drag", "to draw")] + : [("Click", "to tap"), ("Drag", "to draw")] + parts.append(("Hold right + click left", "for the quick ring")) if Preferences.interactKey != .off { parts.append(("Hold \(Preferences.interactKey.symbol)", "to click for real")) } @@ -475,6 +590,7 @@ final class OverlayController { armView.held = false downPoint = nil armView.ink.clear() + closeRing() window.ignoresMouseEvents = true window.orderOut(nil) toolbar.hide() @@ -539,7 +655,14 @@ final class OverlayController { private func setEscEnabled(_ enabled: Bool) { if enabled && isActive && escHotKey == nil { escHotKey = HotKeyCenter.shared.register(keyCode: UInt32(kVK_Escape), modifiers: 0) { [weak self] in - self?.exit() + guard let self = self else { + return + } + if self.armView.ring.isOpen { + self.closeRing() + return + } + self.exit() } } if !enabled, let id = escHotKey { @@ -667,7 +790,7 @@ final class OverlayController { return } let still = Date().timeIntervalSince(stillSince) > OverlayController.idleAfter - guard still, !busy, !armView.held, !armView.isSettling, !armView.ink.isDrawing else { + guard still, !busy, !armView.held, !armView.isSettling, !armView.ink.isDrawing, !armView.ring.isOpen else { return } installIdleMonitors() @@ -714,6 +837,7 @@ final class OverlayController { revealUntil = Date().addingTimeInterval(1.5) } armView.ink.clear() + closeRing() window.setFrame(screen.frame, display: false) armView.frame = NSRect(origin: .zero, size: screen.frame.size) toolbar.place(on: screen) @@ -726,7 +850,8 @@ final class OverlayController { updateZones(mouse: mouse, screen: screen) let overDock = DockArea.contains(OverlayController.cgPoint(mouse)) - let catchMouse = overDock && inputTap.isInstalled && !normalCursor && !interactHeld && !inputTap.clickThrough + // In real-click mode left clicks must reach the Dock, so never catch the mouse there. + let catchMouse = !inputTap.realClicks && overDock && inputTap.isInstalled && !normalCursor && !interactHeld && !inputTap.clickThrough if window.ignoresMouseEvents == catchMouse { window.ignoresMouseEvents = !catchMouse } @@ -738,17 +863,22 @@ final class OverlayController { let local = CGPoint(x: mouse.x - screen.frame.origin.x, y: mouse.y - screen.frame.origin.y) armView.aim(cursor: local) + if armView.ring.isOpen { + armView.ring.hover(local) + } guard downPoint != nil else { let busy = overDock || (!normalCursor && urgent) || CursorHider.isSettling updatePace(busy: busy) return } - let down = CGEventSource.buttonState(.hidSystemState, button: .left) + let down = inputTap.realClicks + ? CGEventSource.buttonState(.hidSystemState, button: .right) || CGEventSource.buttonState(.hidSystemState, button: .center) + : CGEventSource.buttonState(.hidSystemState, button: .left) if down { buttonSeenDown = true } if buttonSeenDown && !down { - release() + release(tapSound: true) return } drag() diff --git a/Sources/TinyHand/Preferences.swift b/Sources/TinyHand/Preferences.swift index 6eff8f7..35d58eb 100644 --- a/Sources/TinyHand/Preferences.swift +++ b/Sources/TinyHand/Preferences.swift @@ -59,7 +59,12 @@ enum InkChoice: String, CaseIterable { } } + /// The colour with the Ink tone setting applied. var color: CGColor? { + baseColor.map(Preferences.inkTone.apply) + } + + private var baseColor: CGColor? { switch self { case .match: return nil case .red: return Ink.red @@ -74,7 +79,127 @@ enum InkChoice: String, CaseIterable { } func resolved(for style: PointerStyle) -> CGColor { - color ?? style.inkColor + color ?? Preferences.inkTone.apply(style.inkColor) + } +} + +/// Shifts every ink colour together: softer, greyer or darker. +enum InkTone: String, CaseIterable { + case vivid, pastel, muted, deep + + var title: String { + switch self { + case .vivid: return "Vivid" + case .pastel: return "Pastel" + case .muted: return "Muted" + case .deep: return "Deep" + } + } + + func apply(_ color: CGColor) -> CGColor { + guard self != .vivid, let ns = NSColor(cgColor: color)?.usingColorSpace(.sRGB) else { + return color + } + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + ns.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + if s < 0.08 { + return color // leave white and black alone + } + switch self { + case .vivid: break + case .pastel: s *= 0.45; b = min(1, b * 0.25 + 0.78) + case .muted: s *= 0.55; b *= 0.82 + case .deep: s = min(1, s * 1.05); b *= 0.62 + } + return NSColor(hue: h, saturation: s, brightness: b, alpha: a).cgColor + } +} + +/// Left-click ripple look. +enum RippleStyle: String, CaseIterable { + case glass, glow, line + + var title: String { + switch self { + case .glass: return "Glass" + case .glow: return "Glow" + case .line: return "Line" + } + } +} + +enum RippleColor: String, CaseIterable { + case blue, teal, purple, gold, white, ink + + var title: String { + switch self { + case .blue: return "Blue" + case .teal: return "Teal" + case .purple: return "Purple" + case .gold: return "Gold" + case .white: return "White" + case .ink: return "Ink" + } + } + + /// nil means "use the current ink colour". + var color: CGColor? { + switch self { + case .blue: return CGColor(srgbRed: 0.45, green: 0.68, blue: 1.00, alpha: 1) + case .teal: return CGColor(srgbRed: 0.22, green: 0.80, blue: 0.78, alpha: 1) + case .purple: return CGColor(srgbRed: 0.62, green: 0.46, blue: 1.00, alpha: 1) + case .gold: return CGColor(srgbRed: 1.00, green: 0.78, blue: 0.32, alpha: 1) + case .white: return CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + case .ink: return nil + } + } +} + +enum RippleSize: String, CaseIterable { + case small, medium, large + + var title: String { rawValue.capitalized } + + var diameter: CGFloat { + switch self { + case .small: return 100 + case .medium: return 140 + case .large: return 200 + } + } +} + +enum RippleSpeed: String, CaseIterable { + case quick, normal, slow + + var title: String { rawValue.capitalized } + + var duration: TimeInterval { + switch self { + case .quick: return 0.4 + case .normal: return 0.55 + case .slow: return 0.85 + } + } +} + +enum PenSize: String, CaseIterable { + case thin, medium, thick + + var title: String { + switch self { + case .thin: return "Thin" + case .medium: return "Medium" + case .thick: return "Thick" + } + } + + var scale: CGFloat { + switch self { + case .thin: return 0.55 + case .medium: return 1 + case .thick: return 1.9 + } } } @@ -109,6 +234,48 @@ enum Preferences { set { set(newValue, "fadeSeconds") } } + static var inkTone: InkTone { + get { defaults.string(forKey: "inkTone").flatMap(InkTone.init(rawValue:)) ?? .vivid } + set { set(newValue.rawValue, "inkTone") } + } + + static var penSize: PenSize { + get { defaults.string(forKey: "penSize").flatMap(PenSize.init(rawValue:)) ?? .medium } + set { set(newValue.rawValue, "penSize") } + } + + /// Off (default): every click is a tap that reaches nothing, as before. + /// On: the left button clicks for real (with a ripple), the right button taps and draws. + static var realClicks: Bool { + get { defaults.object(forKey: "realClicks") as? Bool ?? false } + set { set(newValue, "realClicks") } + } + + static var rippleOn: Bool { + get { defaults.object(forKey: "ripple.on") as? Bool ?? true } + set { set(newValue, "ripple.on") } + } + + static var rippleStyle: RippleStyle { + get { defaults.string(forKey: "ripple.style").flatMap(RippleStyle.init(rawValue:)) ?? .glass } + set { set(newValue.rawValue, "ripple.style") } + } + + static var rippleColor: RippleColor { + get { defaults.string(forKey: "ripple.color").flatMap(RippleColor.init(rawValue:)) ?? .blue } + set { set(newValue.rawValue, "ripple.color") } + } + + static var rippleSize: RippleSize { + get { defaults.string(forKey: "ripple.size").flatMap(RippleSize.init(rawValue:)) ?? .medium } + set { set(newValue.rawValue, "ripple.size") } + } + + static var rippleSpeed: RippleSpeed { + get { defaults.string(forKey: "ripple.speed").flatMap(RippleSpeed.init(rawValue:)) ?? .normal } + set { set(newValue.rawValue, "ripple.speed") } + } + static var handSound: Bool { get { defaults.object(forKey: "sound.hands") as? Bool ?? true } set { set(newValue, "sound.hands") } @@ -187,7 +354,8 @@ 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", "inkTone", "penSize", + "realClicks", "ripple.on", "ripple.style", "ripple.color", "ripple.size", "ripple.speed"] { defaults.removeObject(forKey: key) } NotificationCenter.default.post(name: changed, object: nil) diff --git a/Sources/TinyHand/SettingsView.swift b/Sources/TinyHand/SettingsView.swift index 4b62fc9..7e3d727 100644 --- a/Sources/TinyHand/SettingsView.swift +++ b/Sources/TinyHand/SettingsView.swift @@ -424,6 +424,12 @@ private struct GeneralPage: View { Text(error).font(.system(size: 12)).foregroundColor(.red) } Card(title: "Clicking for real") { + Row("Left button clicks for real", detail: "Left clicks reach the app, with a ripple. Right-click taps, right-drag draws.") { + Toggle("", isOn: model.binding({ Preferences.realClicks }, { Preferences.realClicks = $0 })) + .toggleStyle(.switch) + .labelsHidden() + .focusable(false) + } Row("Hold to use the app", detail: "Clicks, drags and scrolls reach the app.") { Picker("", selection: model.binding({ Preferences.interactKey }, { Preferences.interactKey = $0 })) { ForEach(InteractKey.allCases, id: \.self) { key in @@ -973,6 +979,7 @@ private struct AppearancePage: View { private struct DrawingPage: View { @ObservedObject var model: SettingsModel private static let choices: [(String, Double)] = [ + ("After 1 second", 1), ("After 3 seconds", 3), ("After 6 seconds", 6), ("After 10 seconds", 10), @@ -980,6 +987,15 @@ private struct DrawingPage: View { ] var body: some View { + VStack(alignment: .leading, spacing: 22) { + inkCard + if Preferences.realClicks { + RippleCard(model: model) + } + } + } + + private var inkCard: some View { Card(title: "Ink") { Row("Ink colour", detail: Preferences.inkChoice == .match ? "Matches each pointer." : "All pointers draw in \(Preferences.inkChoice.title.lowercased()).") { HStack(spacing: 8) { @@ -991,6 +1007,28 @@ private struct DrawingPage: View { } } } + Row("Ink tone", detail: "Shifts every colour together: Pastel is softer, Muted is greyer, Deep is darker.") { + Picker("", selection: model.binding({ Preferences.inkTone }, { Preferences.inkTone = $0 })) { + ForEach(InkTone.allCases, id: \.self) { tone in + Text(tone.title).tag(tone) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + .focusable(false) + } + Row("Pen size") { + Picker("", selection: model.binding({ Preferences.penSize }, { Preferences.penSize = $0 })) { + ForEach(PenSize.allCases, id: \.self) { size in + Text(size.title).tag(size) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + .focusable(false) + } Row("Lines fade", detail: "Clear them any time with \(Preferences.clearShortcut.display).") { Picker("", selection: model.binding({ Preferences.fadeSeconds }, { Preferences.fadeSeconds = $0 })) { ForEach(DrawingPage.choices, id: \.1) { choice in @@ -1006,6 +1044,59 @@ private struct DrawingPage: View { } } +private struct RippleCard: View { + @ObservedObject var model: SettingsModel + + var body: some View { + Card(title: "Click ripple") { + Row("Ripple on left-click", detail: "A ring spreads out where you click, in real-click mode.") { + Toggle("", isOn: model.binding({ Preferences.rippleOn }, { Preferences.rippleOn = $0 })) + .toggleStyle(.switch).labelsHidden().focusable(false) + } + if Preferences.rippleOn { + Row("Style", detail: "Glass blurs what's underneath. Glow is a soft bloom. Line is a thin ring.") { + segmented({ Preferences.rippleStyle }, { Preferences.rippleStyle = $0 }, RippleStyle.allCases) { $0.title } + } + Row("Colour", detail: Preferences.rippleColor == .ink ? "Follows your ink colour." : nil) { + Picker("", selection: model.binding({ Preferences.rippleColor }, { Preferences.rippleColor = $0 })) { + ForEach(RippleColor.allCases, id: \.self) { choice in + Text(choice.title).tag(choice) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .focusable(false) + } + Row("Size") { + segmented({ Preferences.rippleSize }, { Preferences.rippleSize = $0 }, RippleSize.allCases) { $0.title } + } + Row("Speed") { + segmented({ Preferences.rippleSpeed }, { Preferences.rippleSpeed = $0 }, RippleSpeed.allCases) { $0.title } + } + Row("Try it", detail: "Shows the ripple right here, without turning the pointer on.") { + PrimaryButton(title: "Preview", symbol: "drop", primary: false) { + NotificationCenter.default.post(name: Splash.previewRequested, object: nil) + } + } + } + } + } + + private func segmented(_ get: @escaping () -> T, _ set: @escaping (T) -> Void, _ options: [T], + _ title: @escaping (T) -> String) -> some View { + Picker("", selection: model.binding(get, set)) { + ForEach(options, id: \.self) { option in + Text(title(option)).tag(option) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + .focusable(false) + } +} + private struct SoundPage: View { @ObservedObject var model: SettingsModel private let columns = [GridItem(.adaptive(minimum: 104, maximum: 140), spacing: 8)] diff --git a/Sources/TinyHand/Splash.swift b/Sources/TinyHand/Splash.swift new file mode 100644 index 0000000..2d7ff18 --- /dev/null +++ b/Sources/TinyHand/Splash.swift @@ -0,0 +1,157 @@ +import AppKit + +/// The left-click ripple: a ring that spreads out from the click and fades, like a drop in water. +/// Three looks (set in Settings → Drawing → Click ripple): +/// - Glass: a thin ring of real frosted glass that blurs whatever is underneath, with a coloured edge. +/// - Glow: a soft coloured bloom, no blur. +/// - Line: a crisp thin ring. +/// +/// Each ripple is its own small click-through window, so the glass can sample the apps below. +/// Windows are pooled and reused. +final class Splash { + struct Look { + var style: RippleStyle + var color: CGColor + var diameter: CGFloat + var duration: TimeInterval + } + + /// Settings → Click ripple posts this to show a ripple under the mouse. + static let previewRequested = Notification.Name("TinyHandRipplePreview") + + private struct Parts { + let window: NSWindow + let glass: NSVisualEffectView + let color: NSImageView + } + + private static let startDiameter: CGFloat = 18 + + private var pool: [Parts] = [] + private var tints: [String: NSImage] = [:] + private let level: NSWindow.Level + + private lazy var glassMask = Splash.ringImage(stops: [ + (NSColor(white: 0, alpha: 0), 0.0), + (NSColor(white: 0, alpha: 0), 0.80), + (NSColor(white: 0, alpha: 0.7), 0.90), + (NSColor(white: 0, alpha: 0), 1.0), + ]) + + /// - Parameter level: window level for the ripples; keep it under the pointer's overlay. + init(level: NSWindow.Level) { + self.level = level + } + + /// - Parameter point: click location in global screen coordinates (bottom-left origin). + func splash(at point: CGPoint, look: Look) { + let parts = take() + let window = parts.window + parts.glass.isHidden = look.style != .glass + parts.color.image = tint(for: look) + + let start = Splash.frame(around: point, diameter: Splash.startDiameter) + let end = Splash.frame(around: point, diameter: look.diameter) + window.setFrame(start, display: true) + window.alphaValue = look.style == .glass ? 0.85 : 0.95 + window.orderFrontRegardless() + + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = look.duration + ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.2, 0.7, 0.3, 1) + window.animator().setFrame(end, display: true) + }) + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = look.duration + ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) + window.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + window.orderOut(nil) + self?.pool.append(parts) + }) + } + + private func take() -> Parts { + if let p = pool.popLast() { + return p + } + let frame = NSRect(x: 0, y: 0, width: Splash.startDiameter, height: Splash.startDiameter) + let w = NSWindow(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false) + w.isOpaque = false + w.backgroundColor = .clear + w.hasShadow = false + w.ignoresMouseEvents = true + w.isReleasedWhenClosed = false + w.level = level + w.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + + let container = NSView(frame: frame) + container.autoresizesSubviews = true + + let glass = NSVisualEffectView(frame: frame) + glass.blendingMode = .behindWindow + glass.material = .popover + glass.appearance = NSAppearance(named: .aqua) // light glass, reads as light catching water on dark screens + glass.state = .active // stay frosted even though TinyHand is never the active app + glass.maskImage = glassMask + glass.autoresizingMask = [.width, .height] + container.addSubview(glass) + + let color = NSImageView(frame: frame) + color.imageScaling = .scaleAxesIndependently + color.autoresizingMask = [.width, .height] + container.addSubview(color) + + w.contentView = container + return Parts(window: w, glass: glass, color: color) + } + + /// The coloured part of the ripple, built once per style and colour. + private func tint(for look: Look) -> NSImage { + let base = NSColor(cgColor: look.color)?.usingColorSpace(.sRGB) ?? .systemBlue + let key = "\(look.style.rawValue)-\(base.redComponent)-\(base.greenComponent)-\(base.blueComponent)" + if let hit = tints[key] { + return hit + } + let light = base.blended(withFraction: 0.6, of: .white) ?? base + func c(_ color: NSColor, _ alpha: CGFloat) -> NSColor { color.withAlphaComponent(alpha) } + let stops: [(NSColor, CGFloat)] + switch look.style { + case .glass: + stops = [(c(light, 0), 0), (c(light, 0), 0.80), (c(light, 0.45), 0.87), (c(base, 0.30), 0.93), (c(base, 0), 1)] + case .glow: + stops = [(c(base, 0), 0), (c(base, 0.06), 0.40), (c(light, 0.22), 0.78), (c(base, 0.18), 0.88), (c(base, 0), 1)] + case .line: + stops = [(c(base, 0), 0), (c(base, 0), 0.89), (c(light, 0.9), 0.935), (c(base, 0), 0.98), (c(base, 0), 1)] + } + let image = Splash.ringImage(stops: stops) + tints[key] = image + return image + } + + private static func frame(around p: CGPoint, diameter d: CGFloat) -> NSRect { + NSRect(x: (p.x - d / 2).rounded(), y: (p.y - d / 2).rounded(), width: d.rounded(), height: d.rounded()) + } + + /// A soft ring: a radial gradient from the centre (location 0) to the edge (location 1). + /// Stretches with the window, so the ring widens as it spreads, like a real wave. + private static func ringImage(stops: [(NSColor, CGFloat)]) -> NSImage { + let size = NSSize(width: 256, height: 256) + let image = NSImage(size: size, flipped: false) { rect in + let colors = stops.map { $0.0 } + var locations = stops.map { $0.1 } + guard let gradient = NSGradient(colors: colors, atLocations: &locations, colorSpace: .sRGB) else { + return false + } + // Explicit radii: location 0 is the centre, 1 is the edge of the circle. + // (Drawing into an oval path instead stretches the gradient out to the image corners, + // which pushed the ring outside the visible circle.) + let c = CGPoint(x: rect.midX, y: rect.midY) + gradient.draw(fromCenter: c, radius: 0, toCenter: c, radius: rect.width / 2, options: []) + return true + } + image.capInsets = NSEdgeInsetsZero + image.resizingMode = .stretch + return image + } +}