diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift index 7038a32c3..cc521a03a 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift @@ -50,7 +50,7 @@ private final class PromptParticleView: NSView { if nextColor != color || seed != distributionSeed { color = nextColor distributionSeed = seed - density = Float(0.82 + unit(lane: 90) * 0.36) + density = Float(0.72 + unit(lane: 90) * 0.26) updateCells() } let inset: CGFloat = 12 @@ -65,8 +65,14 @@ private final class PromptParticleView: NSView { width: 3, height: max(12, subject.height - inset * 2)), ] let lengths = sources.enumerated().map { $0.offset % 2 == 0 ? $0.element.width : $0.element.height } - let perimeter = max(1, lengths.reduce(0, +)) - edgeWeights = lengths.map { Float($0 / perimeter) } + // Bias each side by the prox seed instead of drawing a mechanically + // even frame. The normalized result stays equally sparse overall, but + // each terminal gets a recognisable little constellation. + let weightedLengths = lengths.enumerated().map { index, length in + length * (0.58 + unit(lane: UInt64(100 + index)) * 0.84) + } + let weightedPerimeter = max(1, weightedLengths.reduce(0, +)) + edgeWeights = weightedLengths.map { Float($0 / weightedPerimeter) } CATransaction.begin() CATransaction.setDisableActions(true) for (i, source) in sources.enumerated() { @@ -105,37 +111,123 @@ private final class PromptParticleView: NSView { (.pi / 2, 0, 1), // top → up (.pi, -1, 0), // left → left ] + let palette = starPalette() + let colorWeights: [Float] = [0.46, 0.34, 0.20] for (i, emitter) in emitters.enumerated() { let vector = vectors[i] - let lane = UInt64(i * 12) - let acceleration = 26 + unit(lane: lane + 1) * 28 - let cell = CAEmitterCell() - cell.contents = Self.particleImage - cell.color = color.withAlphaComponent(0.84).cgColor - cell.birthRate = 1 - cell.lifetime = Float(1.7 + unit(lane: lane + 2) * 0.8) - cell.lifetimeRange = Float(0.55 + unit(lane: lane + 3) * 0.65) - cell.velocity = 29 + unit(lane: lane + 4) * 21 - cell.velocityRange = 13 + unit(lane: lane + 5) * 18 - cell.xAcceleration = vector.dx * acceleration - cell.yAcceleration = vector.dy * acceleration - cell.emissionLongitude = vector.angle + (unit(lane: lane + 6) - 0.5) * 0.42 - // Broad but still outward-facing: random tangential motion makes - // the perimeter feel turbulent without sending particles back - // through the terminal that emitted them. - cell.emissionRange = 0.72 + unit(lane: lane + 7) * 0.72 - cell.scale = 0.32 + unit(lane: lane + 8) * 0.23 - cell.scaleRange = 0.16 + unit(lane: lane + 9) * 0.18 - cell.scaleSpeed = -(0.04 + unit(lane: lane + 10) * 0.10) - cell.spin = 0 - cell.spinRange = .pi * 2 - cell.alphaSpeed = -Float(0.25 + unit(lane: lane + 11) * 0.22) - emitter.emitterCells = [cell] + let edgeLane = UInt64(i * 40) + let acceleration = 26 + unit(lane: edgeLane + 1) * 28 + emitter.emitterCells = palette.enumerated().flatMap { colorIndex, starColor -> [CAEmitterCell] in + let lane = edgeLane + UInt64(colorIndex * 12) + let cell = CAEmitterCell() + cell.contents = Self.particleImage + cell.color = starColor.cgColor + cell.redRange = 0.12 + cell.greenRange = 0.12 + cell.blueRange = 0.12 + cell.redSpeed = Float((unit(lane: lane + 30) - 0.5) * 0.24) + cell.greenSpeed = Float((unit(lane: lane + 31) - 0.5) * 0.24) + cell.blueSpeed = Float((unit(lane: lane + 32) - 0.5) * 0.24) + cell.birthRate = colorWeights[colorIndex] + // Short, varied lives make individual glints wink through the + // longer flow instead of reading as a continuous dot stream. + cell.lifetime = Float(0.9 + unit(lane: lane + 2) * 0.75) + cell.lifetimeRange = Float(0.35 + unit(lane: lane + 3) * 0.40) + cell.velocity = 25 + unit(lane: lane + 4) * 19 + cell.velocityRange = 11 + unit(lane: lane + 5) * 15 + cell.xAcceleration = vector.dx * acceleration + cell.yAcceleration = vector.dy * acceleration + cell.emissionLongitude = vector.angle + (unit(lane: lane + 6) - 0.5) * 0.42 + // Broad but still outward-facing: random tangential motion + // makes the composition breathe without crossing the window. + cell.emissionRange = 0.72 + unit(lane: lane + 7) * 0.72 + cell.scale = 0.14 + unit(lane: lane + 8) * 0.06 + cell.scaleRange = 0.035 + unit(lane: lane + 9) * 0.025 + cell.scaleSpeed = -(0.008 + unit(lane: lane + 10) * 0.018) + cell.spin = 0 + cell.spinRange = .pi * 2 + cell.alphaRange = 0.16 + cell.alphaSpeed = -Float(0.32 + unit(lane: lane + 11) * 0.24) + + // A rarer, short-lived specular cell flashes over the soft + // moving star. Its crisp rays briefly bloom beyond the halo, + // giving each colour its own unsynchronised little wink. + let glint = CAEmitterCell() + glint.contents = Self.glintImage + glint.color = starColor.withAlphaComponent(0.96).cgColor + glint.redRange = 0.20 + glint.greenRange = 0.20 + glint.blueRange = 0.20 + glint.redSpeed = Float((unit(lane: lane + 33) - 0.5) * 0.42) + glint.greenSpeed = Float((unit(lane: lane + 34) - 0.5) * 0.42) + glint.blueSpeed = Float((unit(lane: lane + 35) - 0.5) * 0.42) + glint.birthRate = colorWeights[colorIndex] * 0.34 + glint.lifetime = Float(0.18 + unit(lane: lane + 20) * 0.22) + glint.lifetimeRange = 0.10 + glint.velocity = 18 + unit(lane: lane + 21) * 12 + glint.velocityRange = 8 + glint.xAcceleration = vector.dx * acceleration + glint.yAcceleration = vector.dy * acceleration + glint.emissionLongitude = cell.emissionLongitude + glint.emissionRange = cell.emissionRange + glint.scale = 0.16 + unit(lane: lane + 22) * 0.09 + glint.scaleRange = 0.045 + glint.scaleSpeed = -0.22 + glint.spinRange = .pi * 0.5 + glint.alphaRange = 0.05 + glint.alphaSpeed = -1.7 + return [cell, glint] + } + installNeonFlicker(on: emitter, lane: edgeLane + 60) + } + } + + /// Independent, hard-edged intensity skips make each side behave like a + /// tiny imperfect neon circuit. Brief dips and rebounds read as flicker; + /// long full-bright stretches keep the field calm and legible. + private func installNeonFlicker(on emitter: CAEmitterLayer, lane: UInt64) { + let animation = CAKeyframeAnimation(keyPath: "opacity") + animation.values = [1.0, 0.96, 0.42, 1.0, 0.82, 1.0, 0.58, 1.0, 1.0] + animation.keyTimes = [0.0, 0.19, 0.205, 0.23, 0.54, + 0.565, 0.59, 0.625, 1.0].map(NSNumber.init) + animation.calculationMode = .discrete + animation.duration = 1.8 + unit(lane: lane) * 2.4 + animation.beginTime = CACurrentMediaTime() - unit(lane: lane + 1) * animation.duration + animation.repeatCount = .infinity + animation.isRemovedOnCompletion = false + emitter.add(animation, forKey: "prox-neon-flicker") + } + + /// Preserve the terminal theme as the dominant note, then bend two smaller + /// notes around the colour wheel. Additive overlap mixes them into new + /// colours against the desktop instead of painting one flat neon tint. + private func starPalette() -> [NSColor] { + guard let rgb = color.usingColorSpace(.deviceRGB) else { + return [color.withAlphaComponent(0.88), + NSColor.systemPink.withAlphaComponent(0.75), + NSColor(deviceHue: 0.52, saturation: 0.78, + brightness: 1, alpha: 0.67)] + } + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + rgb.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + let offsets: [CGFloat] = [0, 0.12, 0.56] + let alphas: [CGFloat] = [0.88, 0.75, 0.67] + return zip(offsets, alphas).map { offset, starAlpha in + let shiftedHue = (hue + offset).truncatingRemainder(dividingBy: 1) + return NSColor(deviceHue: shiftedHue, + saturation: max(0.58, min(0.94, saturation * 0.88 + 0.12)), + brightness: max(0.88, brightness), + alpha: starAlpha) } } private static let particleImage: CGImage? = { - let size = CGSize(width: 12, height: 12) + // The texture carries a broad halo and a four-point core; emitter scale + // reduces the whole thing to a two-to-four-pixel flickering star. + let size = CGSize(width: 24, height: 24) guard let context = CGContext( data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: Int(size.width) * 4, @@ -144,14 +236,59 @@ private final class PromptParticleView: NSView { let gradient = CGGradient( colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [NSColor.white.cgColor, - NSColor.white.withAlphaComponent(0.5).cgColor, + NSColor.white.withAlphaComponent(0.82).cgColor, + NSColor.white.withAlphaComponent(0.22).cgColor, NSColor.clear.cgColor] as CFArray, - locations: [0, 0.36, 1]) else { return nil } + locations: [0, 0.14, 0.48, 1]) else { return nil } let center = CGPoint(x: size.width / 2, y: size.width / 2) context.drawRadialGradient( gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: size.width / 2, options: [.drawsAfterEndLocation]) + context.setFillColor(NSColor.white.withAlphaComponent(0.94).cgColor) + context.move(to: CGPoint(x: center.x, y: center.y - 6)) + context.addLine(to: CGPoint(x: center.x + 1.15, y: center.y - 1.15)) + context.addLine(to: CGPoint(x: center.x + 6, y: center.y)) + context.addLine(to: CGPoint(x: center.x + 1.15, y: center.y + 1.15)) + context.addLine(to: CGPoint(x: center.x, y: center.y + 6)) + context.addLine(to: CGPoint(x: center.x - 1.15, y: center.y + 1.15)) + context.addLine(to: CGPoint(x: center.x - 6, y: center.y)) + context.addLine(to: CGPoint(x: center.x - 1.15, y: center.y - 1.15)) + context.closePath() + context.fillPath() + return context.makeImage() + }() + + /// A crisp, long-rayed companion used only for very brief specular pops. + private static let glintImage: CGImage? = { + let size = CGSize(width: 32, height: 32) + guard let context = CGContext( + data: nil, width: Int(size.width), height: Int(size.height), + bitsPerComponent: 8, bytesPerRow: Int(size.width) * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue), + let gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [NSColor.white.cgColor, + NSColor.white.withAlphaComponent(0.42).cgColor, + NSColor.clear.cgColor] as CFArray, + locations: [0, 0.24, 1]) else { return nil } + let center = CGPoint(x: size.width / 2, y: size.height / 2) + context.drawRadialGradient( + gradient, startCenter: center, startRadius: 0, + endCenter: center, endRadius: 9, + options: [.drawsAfterEndLocation]) + context.setFillColor(NSColor.white.cgColor) + context.move(to: CGPoint(x: center.x, y: center.y - 14)) + context.addLine(to: CGPoint(x: center.x + 1.2, y: center.y - 1.8)) + context.addLine(to: CGPoint(x: center.x + 7, y: center.y)) + context.addLine(to: CGPoint(x: center.x + 1.2, y: center.y + 1.8)) + context.addLine(to: CGPoint(x: center.x, y: center.y + 14)) + context.addLine(to: CGPoint(x: center.x - 1.2, y: center.y + 1.8)) + context.addLine(to: CGPoint(x: center.x - 7, y: center.y)) + context.addLine(to: CGPoint(x: center.x - 1.2, y: center.y - 1.8)) + context.closePath() + context.fillPath() return context.makeImage() }() } @@ -226,7 +363,7 @@ final class PromptFocusHighlight { width: windowFrame.width, height: windowFrame.height) effect.view.configure(subject: subject, color: target.color, seed: target.seed) - effect.view.emissionRate = target.windowID == focusedID ? 24 : 11 + effect.view.emissionRate = target.windowID == focusedID ? 18 : 8 effect.view.isEmitting = true // All particle fields share one place immediately above the // frontmost tracked terminal. They therefore paint across the