diff --git a/Core/Labels/LabelLayer.swift b/Core/Labels/LabelLayer.swift index b1cfc78..0a90e8d 100644 --- a/Core/Labels/LabelLayer.swift +++ b/Core/Labels/LabelLayer.swift @@ -1,12 +1,23 @@ import Cocoa import QuartzCore +/// Direction of the caret (arrow) on the speech bubble +enum CaretDirection { + case up // Caret points up (label below element) + case down // Caret points down (label above element) +} + /// A CALayer that draws a label for a UI element with speech bubble style final class LabelLayer: CALayer { var label: String = "" { didSet { updateLayout() } } + /// Direction of the caret - set by positionRelativeTo + private(set) var caretDirection: CaretDirection = .up { + didSet { if oldValue != caretDirection { updateLayout() } } + } + var labelFont: NSFont = .monospacedSystemFont(ofSize: 10, weight: .bold) { didSet { updateLayout() } } @@ -53,10 +64,16 @@ final class LabelLayer: CALayer { } private func setupLayer() { + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + + // Set scale on self for Retina display + contentsScale = scale + // Shape layer for speech bubble shapeLayer.fillColor = labelBackgroundColor.cgColor shapeLayer.strokeColor = labelBorderColor.cgColor shapeLayer.lineWidth = 1 + shapeLayer.contentsScale = scale addSublayer(shapeLayer) // Shadow on shape @@ -66,7 +83,7 @@ final class LabelLayer: CALayer { shapeLayer.shadowOpacity = 0.4 // Text layer - textLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + textLayer.contentsScale = scale textLayer.alignmentMode = .center textLayer.truncationMode = .none addSublayer(textLayer) @@ -88,67 +105,127 @@ final class LabelLayer: CALayer { bounds = CGRect(x: 0, y: 0, width: bubbleWidth, height: totalHeight) - // Create speech bubble path with caret pointing up - let path = createBubblePath(width: bubbleWidth, height: totalHeight, bubbleHeight: bubbleHeight) + // Create speech bubble path with caret in the appropriate direction + let path = createBubblePath( + width: bubbleWidth, + height: totalHeight, + bubbleHeight: bubbleHeight, + direction: caretDirection + ) shapeLayer.path = path shapeLayer.frame = bounds - // Position text in the bubble (below the caret) + // Position text in the bubble + // When caret points up: text is at bottom (y = yPadding) + // When caret points down: text is above the caret (y = yPadding + caretHeight) + let textY = caretDirection == .up ? yPadding : yPadding + caretHeight textLayer.frame = CGRect( x: xPadding, - y: yPadding, + y: textY, width: textSize.width, height: textSize.height ) } - private func createBubblePath(width: CGFloat, height: CGFloat, bubbleHeight: CGFloat) -> CGPath { + private func createBubblePath( + width: CGFloat, + height: CGFloat, + bubbleHeight: CGFloat, + direction: CaretDirection + ) -> CGPath { let path = CGMutablePath() let cornerRadius: CGFloat = 3 let caretX = width / 2 - // Start from bottom-left, go clockwise - // Bottom-left corner - path.move(to: CGPoint(x: 0, y: cornerRadius)) - - // Left edge up to top-left corner - path.addLine(to: CGPoint(x: 0, y: bubbleHeight - cornerRadius)) - path.addArc( - tangent1End: CGPoint(x: 0, y: bubbleHeight), - tangent2End: CGPoint(x: cornerRadius, y: bubbleHeight), - radius: cornerRadius - ) - - // Top edge to caret left - path.addLine(to: CGPoint(x: caretX - caretWidth / 2, y: bubbleHeight)) - - // Caret pointing up - path.addLine(to: CGPoint(x: caretX, y: height)) - path.addLine(to: CGPoint(x: caretX + caretWidth / 2, y: bubbleHeight)) - - // Top edge to top-right corner - path.addLine(to: CGPoint(x: width - cornerRadius, y: bubbleHeight)) - path.addArc( - tangent1End: CGPoint(x: width, y: bubbleHeight), - tangent2End: CGPoint(x: width, y: bubbleHeight - cornerRadius), - radius: cornerRadius - ) - - // Right edge down to bottom-right corner - path.addLine(to: CGPoint(x: width, y: cornerRadius)) - path.addArc( - tangent1End: CGPoint(x: width, y: 0), - tangent2End: CGPoint(x: width - cornerRadius, y: 0), - radius: cornerRadius - ) - - // Bottom edge to bottom-left corner - path.addLine(to: CGPoint(x: cornerRadius, y: 0)) - path.addArc( - tangent1End: CGPoint(x: 0, y: 0), - tangent2End: CGPoint(x: 0, y: cornerRadius), - radius: cornerRadius - ) + switch direction { + case .up: + // Caret at top, pointing up (label below element) + // Bubble body is at bottom (y: 0 to bubbleHeight) + // Caret tip is at top (y: height) + + path.move(to: CGPoint(x: 0, y: cornerRadius)) + + // Left edge up + path.addLine(to: CGPoint(x: 0, y: bubbleHeight - cornerRadius)) + path.addArc( + tangent1End: CGPoint(x: 0, y: bubbleHeight), + tangent2End: CGPoint(x: cornerRadius, y: bubbleHeight), + radius: cornerRadius + ) + + // Top edge to caret + path.addLine(to: CGPoint(x: caretX - caretWidth / 2, y: bubbleHeight)) + path.addLine(to: CGPoint(x: caretX, y: height)) // Caret tip + path.addLine(to: CGPoint(x: caretX + caretWidth / 2, y: bubbleHeight)) + + // Top edge to top-right corner + path.addLine(to: CGPoint(x: width - cornerRadius, y: bubbleHeight)) + path.addArc( + tangent1End: CGPoint(x: width, y: bubbleHeight), + tangent2End: CGPoint(x: width, y: bubbleHeight - cornerRadius), + radius: cornerRadius + ) + + // Right edge down + path.addLine(to: CGPoint(x: width, y: cornerRadius)) + path.addArc( + tangent1End: CGPoint(x: width, y: 0), + tangent2End: CGPoint(x: width - cornerRadius, y: 0), + radius: cornerRadius + ) + + // Bottom edge + path.addLine(to: CGPoint(x: cornerRadius, y: 0)) + path.addArc( + tangent1End: CGPoint(x: 0, y: 0), + tangent2End: CGPoint(x: 0, y: cornerRadius), + radius: cornerRadius + ) + + case .down: + // Caret at bottom, pointing down (label above element) + // Caret tip is at bottom (y: 0) + // Bubble body is at top (y: caretHeight to height) + + path.move(to: CGPoint(x: 0, y: caretHeight + cornerRadius)) + + // Left edge up + path.addLine(to: CGPoint(x: 0, y: height - cornerRadius)) + path.addArc( + tangent1End: CGPoint(x: 0, y: height), + tangent2End: CGPoint(x: cornerRadius, y: height), + radius: cornerRadius + ) + + // Top edge + path.addLine(to: CGPoint(x: width - cornerRadius, y: height)) + path.addArc( + tangent1End: CGPoint(x: width, y: height), + tangent2End: CGPoint(x: width, y: height - cornerRadius), + radius: cornerRadius + ) + + // Right edge down + path.addLine(to: CGPoint(x: width, y: caretHeight + cornerRadius)) + path.addArc( + tangent1End: CGPoint(x: width, y: caretHeight), + tangent2End: CGPoint(x: width - cornerRadius, y: caretHeight), + radius: cornerRadius + ) + + // Bottom edge to caret + path.addLine(to: CGPoint(x: caretX + caretWidth / 2, y: caretHeight)) + path.addLine(to: CGPoint(x: caretX, y: 0)) // Caret tip pointing down + path.addLine(to: CGPoint(x: caretX - caretWidth / 2, y: caretHeight)) + + // Bottom edge to bottom-left corner + path.addLine(to: CGPoint(x: cornerRadius, y: caretHeight)) + path.addArc( + tangent1End: CGPoint(x: 0, y: caretHeight), + tangent2End: CGPoint(x: 0, y: caretHeight + cornerRadius), + radius: cornerRadius + ) + } path.closeSubpath() return path @@ -166,22 +243,43 @@ final class LabelLayer: CALayer { ) } - /// Position the label below a UI element frame with caret pointing up + /// Position the label relative to a UI element frame + /// For elements near the bottom of screen: label above with caret pointing down + /// For other elements: label below with caret pointing up func positionRelativeTo(frame: CGRect, screenHeight: CGFloat) { // Convert from accessibility coordinates (top-left origin, Y down) // to layer coordinates (bottom-left origin, Y up) - // Element bottom edge in layer coordinates + let elementTop = screenHeight - frame.origin.y let elementBottom = screenHeight - (frame.origin.y + frame.height) // Label center X = element center X let x = frame.origin.x + (frame.width / 2) - // Label top (caret tip) should touch element bottom - // position.y is at center of bounds, caret tip is at bounds.height from bottom - // So: position.y + bounds.height/2 = elementBottom - // Therefore: position.y = elementBottom - bounds.height/2 - let y = elementBottom - bounds.height / 2 + // Determine if element is in bottom portion of screen + // Use accessibility coordinates (Y down from top) + let bottomThreshold = screenHeight * 0.75 + let isNearBottom = frame.origin.y > bottomThreshold + + var y: CGFloat + if isNearBottom { + // Position label above element, caret points down + caretDirection = .down + // Caret tip (at y=0 of bounds) should touch element top + // position.y - bounds.height/2 = elementTop + y = elementTop + bounds.height / 2 + } else { + // Position label below element, caret points up + caretDirection = .up + // Caret tip (at y=bounds.height of bounds) should touch element bottom + // position.y + bounds.height/2 = elementBottom + y = elementBottom - bounds.height / 2 + } + + // Ensure label stays within screen bounds + let minY = bounds.height / 2 + 10 + let maxY = screenHeight - bounds.height / 2 - 10 + y = max(minY, min(y, maxY)) position = CGPoint(x: x, y: y) }