From be15d1690a98abc0f178dfd930d522b7926a1b30 Mon Sep 17 00:00:00 2001 From: trussosw Date: Tue, 10 Nov 2026 23:34:15 -0500 Subject: [PATCH 1/2] 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/2] 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)