Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 153 additions & 55 deletions Core/Labels/LabelLayer.swift
Original file line number Diff line number Diff line change
@@ -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() }
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
Loading