From be15d1690a98abc0f178dfd930d522b7926a1b30 Mon Sep 17 00:00:00 2001 From: trussosw Date: Tue, 10 Nov 2026 23:34:15 -0500 Subject: [PATCH 1/3] Add pen size, ink tone and a 1 second fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small drawing options in Settings → Drawing: - Pen size: Thin, Medium (today's width) or Thick. - Ink tone: Vivid (today's colours), Pastel, Muted or Deep. Shifts every ink colour together, including Match the pointer; white and black stay as they are. - Lines fade: adds After 1 second. Both new settings are cleared by Reset like the rest. Co-Authored-By: Claude Opus 5.5 --- Sources/TinyHand/ArmView.swift | 2 +- Sources/TinyHand/Preferences.swift | 71 ++++++++++++++++++++++++++++- Sources/TinyHand/SettingsView.swift | 23 ++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/Sources/TinyHand/ArmView.swift b/Sources/TinyHand/ArmView.swift index 7de9cee..3c4fefc 100644 --- a/Sources/TinyHand/ArmView.swift +++ b/Sources/TinyHand/ArmView.swift @@ -20,7 +20,7 @@ final class ArmView: NSView { let ink = Ink() 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? diff --git a/Sources/TinyHand/Preferences.swift b/Sources/TinyHand/Preferences.swift index 6eff8f7..2d3e5de 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,59 @@ 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 + } +} + +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 +166,16 @@ 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") } + } + static var handSound: Bool { get { defaults.object(forKey: "sound.hands") as? Bool ?? true } set { set(newValue, "sound.hands") } @@ -187,7 +254,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", "inkTone", "penSize"] { 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..a8eb08f 100644 --- a/Sources/TinyHand/SettingsView.swift +++ b/Sources/TinyHand/SettingsView.swift @@ -973,6 +973,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), @@ -991,6 +992,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 From 2c0c56298d82497f783c676d9da2c68fb95a1f74 Mon Sep 17 00:00:00 2001 From: trussosw Date: Tue, 10 Nov 2026 23:34:15 -0500 Subject: [PATCH 2/3] Add a quick ring: hold right, click left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold the right button and click left to open a small ring at the pointer: - Left half: six ink colours. - Right half: up to six pointers (the pinned toolbar pointers, always including the one in use). - Inside the colours: three pen sizes (thick, medium, thin). - Centre: how long drawings last. Each click cycles Stay → 1 s → 3 s → 6 s; leaving Stay fades what's already drawn. A label under the ring names whatever is hovered. Click an item, or slide onto it and let go of the right button. Click outside or press Esc to close. Colours and pointers close the ring; pen size and timing keep it open so several can be changed in one go. Builds on the pen size / ink tone change (uses Preferences.penSize). Co-Authored-By: Claude Opus 5.5 --- Sources/TinyHand/ArmView.swift | 2 + Sources/TinyHand/Ink.swift | 7 + Sources/TinyHand/InkRing.swift | 384 +++++++++++++++++++++++ Sources/TinyHand/InputTap.swift | 73 ++++- Sources/TinyHand/OverlayController.swift | 97 +++++- 5 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 Sources/TinyHand/InkRing.swift diff --git a/Sources/TinyHand/ArmView.swift b/Sources/TinyHand/ArmView.swift index 3c4fefc..9baf5ca 100644 --- a/Sources/TinyHand/ArmView.swift +++ b/Sources/TinyHand/ArmView.swift @@ -19,6 +19,7 @@ final class ArmView: NSView { private let liftFrac: CGFloat = 0.06 let ink = Ink() + let ring = InkRing() var held = false var inkWidth: CGFloat { max(2, max(3, bounds.height * 0.0055) * Preferences.penSize.scale) } @@ -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..90278c3 100644 --- a/Sources/TinyHand/InputTap.swift +++ b/Sources/TinyHand/InputTap.swift @@ -8,8 +8,21 @@ final class InputTap { var onLeftDragged: (() -> 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 _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 +118,7 @@ final class InputTap { lock.withLock { _active = value _interactPress = false + _leftCaptured = false } guard let tap = tap else { return @@ -184,6 +198,15 @@ 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 + } + switch type { case .leftMouseDown, .rightMouseDown, .otherMouseDown: if InputTap.screenLocked { @@ -196,7 +219,12 @@ 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 { @@ -209,6 +237,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..b35be47 100644 --- a/Sources/TinyHand/OverlayController.swift +++ b/Sources/TinyHand/OverlayController.swift @@ -92,6 +92,25 @@ final class OverlayController { self?.release() self?.noteButtonEvent() } + 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 @@ -357,6 +376,65 @@ 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 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() } @@ -436,7 +514,8 @@ 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)] = [("Click", "to tap"), ("Drag", "to draw"), + ("Hold right + click left", "for the quick ring")] if Preferences.interactKey != .off { parts.append(("Hold \(Preferences.interactKey.symbol)", "to click for real")) } @@ -475,6 +554,7 @@ final class OverlayController { armView.held = false downPoint = nil armView.ink.clear() + closeRing() window.ignoresMouseEvents = true window.orderOut(nil) toolbar.hide() @@ -539,7 +619,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 +754,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 +801,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) @@ -738,6 +826,9 @@ 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) From efebd3c14818504a2a86c7436e83395f679977fd Mon Sep 17 00:00:00 2001 From: trussosw Date: Tue, 10 Nov 2026 23:34:15 -0500 Subject: [PATCH 3/3] Add an optional real-click mode with a click ripple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new setting, off by default, in Settings → General → Clicking for real: "Left button clicks for real". When on: - The left button works as a normal mouse (click, drag, select), with the tap sound and a short ripple where you clicked. - Right-click is the tap (nothing underneath gets clicked); right-drag draws. The tap sound plays on release only when you didn't drag, so drawing stays silent. - The overlay no longer catches clicks over the Dock, so left clicks reach it. The ripple has its own section in Settings → Drawing (shown in this mode): on/off, style (Glass blurs what's underneath, Glow, Line), colour (or follow the ink), size, speed, and a Preview button. When off, everything behaves exactly as before. Builds on the quick ring change (hold right + click left works in both modes). Co-Authored-By: Claude Opus 5.5 --- Sources/TinyHand/InputTap.swift | 27 +++- Sources/TinyHand/OverlayController.swift | 57 ++++++-- Sources/TinyHand/Preferences.swift | 103 ++++++++++++++- Sources/TinyHand/SettingsView.swift | 68 ++++++++++ Sources/TinyHand/Splash.swift | 157 +++++++++++++++++++++++ 5 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 Sources/TinyHand/Splash.swift diff --git a/Sources/TinyHand/InputTap.swift b/Sources/TinyHand/InputTap.swift index 90278c3..81616fa 100644 --- a/Sources/TinyHand/InputTap.swift +++ b/Sources/TinyHand/InputTap.swift @@ -5,7 +5,9 @@ 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. @@ -16,6 +18,13 @@ final class InputTap { 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. @@ -207,6 +216,18 @@ final class InputTap { 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 { @@ -227,8 +248,8 @@ final class InputTap { } 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) diff --git a/Sources/TinyHand/OverlayController.swift b/Sources/TinyHand/OverlayController.swift index b35be47..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,40 @@ 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() } @@ -156,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) } @@ -347,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)") @@ -390,6 +416,13 @@ final class OverlayController { 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) @@ -501,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) @@ -514,8 +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"), - ("Hold right + click left", "for the quick ring")] + 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")) } @@ -814,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 } @@ -834,12 +871,14 @@ final class OverlayController { 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 2d3e5de..35d58eb 100644 --- a/Sources/TinyHand/Preferences.swift +++ b/Sources/TinyHand/Preferences.swift @@ -115,6 +115,74 @@ enum InkTone: String, CaseIterable { } } +/// 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 @@ -176,6 +244,38 @@ enum Preferences { 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") } @@ -254,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", "inkTone", "penSize"] { + "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 a8eb08f..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 @@ -981,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) { @@ -1029,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 + } +}