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..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) 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