//go:build darwin
|
|
package overlay
|
|
/*
|
#cgo CFLAGS: -x objective-c
|
#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices
|
#import <Cocoa/Cocoa.h>
|
#import <ApplicationServices/ApplicationServices.h>
|
#include <dispatch/dispatch.h>
|
#include <math.h>
|
#include <string.h>
|
|
// ---- Visual constants for the borderless macOS light-wave overlay ----
|
#define CAP_W 620.0f
|
#define CAP_H 112.0f
|
#define DRAG_W 280.0f
|
#define DRAG_H 76.0f
|
#define BAR_W 7.0f
|
#define BAR_GAP 13.0f
|
#define BAR_R 3.5f
|
#define N_BARS 11
|
#define TXT_SZ 22.0f
|
#define TXT_GAP 52.0f
|
|
// ---- Shared state (written by Go, read on main thread) ----
|
typedef struct {
|
float barHeights[N_BARS];
|
float barR, barG, barB;
|
char text[128];
|
int status;
|
float volumes[N_BARS];
|
float animTime;
|
float fadeProgress;
|
float fadeTarget;
|
int posX, posY;
|
int needsPosition;
|
int hasPosition;
|
int dragEnded;
|
int dragX, dragY;
|
} OverlayState;
|
|
static OverlayState g_state = {
|
.barHeights = {14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14},
|
.barR = 0, .barG = 0.478f, .barB = 1.0f,
|
};
|
|
static float clamp01(float v) {
|
if (v < 0) return 0;
|
if (v > 1) return 1;
|
return v;
|
}
|
|
static float mixf(float a, float b, float t) {
|
return a + (b - a) * clamp01(t);
|
}
|
|
static void spectralColor(float p, float* r, float* g, float* b) {
|
float c1[3] = {0.19f, 0.88f, 1.00f};
|
float c2[3] = {0.10f, 0.46f, 1.00f};
|
float c3[3] = {0.56f, 0.36f, 1.00f};
|
float c4[3] = {1.00f, 0.30f, 0.84f};
|
float c5[3] = {1.00f, 0.62f, 0.74f};
|
float* a = c1;
|
float* z = c2;
|
float t = 0;
|
|
if (p < 0.25f) {
|
a = c1; z = c2; t = p / 0.25f;
|
} else if (p < 0.55f) {
|
a = c2; z = c3; t = (p - 0.25f) / 0.30f;
|
} else if (p < 0.80f) {
|
a = c3; z = c4; t = (p - 0.55f) / 0.25f;
|
} else {
|
a = c4; z = c5; t = (p - 0.80f) / 0.20f;
|
}
|
|
*r = mixf(a[0], z[0], t);
|
*g = mixf(a[1], z[1], t);
|
*b = mixf(a[2], z[2], t);
|
}
|
|
static void applyStatusTint(float* r, float* g, float* b, float amount) {
|
*r = mixf(*r, g_state.barR, amount);
|
*g = mixf(*g, g_state.barG, amount);
|
*b = mixf(*b, g_state.barB, amount);
|
}
|
|
static void drawEllipticalGlow(CGContextRef ctx, float cx, float cy, float rx, float ry,
|
float r, float g, float b, float alpha) {
|
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
|
if (!colorSpace) return;
|
CGFloat locations[] = {0.0f, 0.55f, 1.0f};
|
CGFloat components[] = {
|
r, g, b, alpha,
|
r, g, b, alpha * 0.28f,
|
r, g, b, 0.0f,
|
};
|
CGGradientRef gradient = CGGradientCreateWithColorComponents(colorSpace, components, locations, 3);
|
if (!gradient) {
|
CGColorSpaceRelease(colorSpace);
|
return;
|
}
|
|
CGContextSaveGState(ctx);
|
CGContextTranslateCTM(ctx, cx, cy);
|
CGContextScaleCTM(ctx, rx, ry);
|
CGContextDrawRadialGradient(ctx, gradient, CGPointMake(0, 0), 0.0f, CGPointMake(0, 0), 1.0f, 0);
|
CGContextRestoreGState(ctx);
|
|
CGGradientRelease(gradient);
|
CGColorSpaceRelease(colorSpace);
|
}
|
|
static void drawWavePath(CGContextRef ctx, float startX, float endX, float centerY,
|
float amplitude, float phase, float cycles, float r, float g, float b, float alpha, float lineWidth) {
|
CGMutablePathRef path = CGPathCreateMutable();
|
if (!path) return;
|
int steps = 180;
|
for (int i = 0; i <= steps; i++) {
|
float p = (float)i / (float)steps;
|
float x = mixf(startX, endX, p);
|
float edgeFade = sinf(p * (float)M_PI);
|
float y = centerY
|
+ sinf(p * cycles * 2.0f * (float)M_PI + phase) * amplitude * edgeFade
|
+ sinf(p * (cycles * 0.55f) * 2.0f * (float)M_PI + phase * 1.7f) * amplitude * 0.36f * edgeFade;
|
if (i == 0) {
|
CGPathMoveToPoint(path, NULL, x, y);
|
} else {
|
CGPathAddLineToPoint(path, NULL, x, y);
|
}
|
}
|
|
CGColorRef glow = CGColorCreateGenericRGB(r, g, b, alpha * 0.65f);
|
CGContextSaveGState(ctx);
|
CGContextSetLineCap(ctx, kCGLineCapRound);
|
CGContextSetLineJoin(ctx, kCGLineJoinRound);
|
if (glow) {
|
CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 9.0f, glow);
|
}
|
CGContextAddPath(ctx, path);
|
CGContextSetRGBStrokeColor(ctx, r, g, b, alpha);
|
CGContextSetLineWidth(ctx, lineWidth);
|
CGContextStrokePath(ctx);
|
CGContextRestoreGState(ctx);
|
if (glow) {
|
CGColorRelease(glow);
|
}
|
CGPathRelease(path);
|
}
|
|
static NSWindow* g_window = nil;
|
static NSWindow* g_dragWindow = nil;
|
|
static float desktopTopY(void) {
|
NSScreen* mainScreen = [NSScreen mainScreen];
|
if (!mainScreen) return 0.0f;
|
return mainScreen.frame.origin.y + mainScreen.frame.size.height;
|
}
|
|
static NSPoint visualOriginFromTopLeft(float x, float y) {
|
return NSMakePoint(x, desktopTopY() - y - CAP_H);
|
}
|
|
static NSPoint topLeftFromVisualOrigin(NSPoint visualOrigin) {
|
return NSMakePoint(visualOrigin.x, desktopTopY() - visualOrigin.y - CAP_H);
|
}
|
|
static NSPoint dragOriginForVisualOrigin(NSPoint visualOrigin) {
|
return NSMakePoint(
|
visualOrigin.x + (CAP_W - DRAG_W) * 0.5f,
|
visualOrigin.y + (CAP_H - DRAG_H) * 0.5f
|
);
|
}
|
|
static void positionOverlayWindows(NSPoint visualOrigin) {
|
if (g_window) {
|
[g_window setFrameOrigin:visualOrigin];
|
}
|
if (g_dragWindow) {
|
[g_dragWindow setFrameOrigin:dragOriginForVisualOrigin(visualOrigin)];
|
}
|
}
|
|
static void storeDragPositionFromVisualOrigin(NSPoint visualOrigin) {
|
NSPoint topLeft = topLeftFromVisualOrigin(visualOrigin);
|
g_state.dragX = (int)topLeft.x;
|
g_state.dragY = (int)topLeft.y;
|
g_state.posX = g_state.dragX;
|
g_state.posY = g_state.dragY;
|
g_state.dragEnded = 1;
|
}
|
|
static BOOL rectLooksUsable(NSRect rect) {
|
return isfinite(rect.origin.x) && isfinite(rect.origin.y) &&
|
isfinite(rect.size.width) && isfinite(rect.size.height) &&
|
rect.size.width > 1.0f && rect.size.height > 1.0f;
|
}
|
|
static CGFloat rectIntersectionArea(NSRect a, NSRect b) {
|
NSRect intersection = NSIntersectionRect(a, b);
|
if (NSIsEmptyRect(intersection)) return 0.0f;
|
return intersection.size.width * intersection.size.height;
|
}
|
|
static BOOL rectIntersectsAnyScreen(NSRect rect) {
|
if (!rectLooksUsable(rect)) return NO;
|
for (NSScreen* screen in [NSScreen screens]) {
|
if (rectIntersectionArea(rect, screen.frame) > 0.0f) return YES;
|
}
|
return NO;
|
}
|
|
static NSRect rectFromTopLeftCGRect(CGRect rect) {
|
return NSMakeRect(
|
rect.origin.x,
|
desktopTopY() - rect.origin.y - rect.size.height,
|
rect.size.width,
|
rect.size.height
|
);
|
}
|
|
static NSRect rectFromTopLeftOrRawCGRect(CGRect rect) {
|
NSRect converted = rectFromTopLeftCGRect(rect);
|
if (rectIntersectsAnyScreen(converted)) return converted;
|
NSRect raw = NSMakeRect(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height);
|
if (rectIntersectsAnyScreen(raw)) return raw;
|
return converted;
|
}
|
|
static BOOL axCopyRectFromElement(AXUIElementRef element, NSRect* outRect) {
|
if (!element || !outRect) return NO;
|
|
CFTypeRef positionValue = NULL;
|
CFTypeRef sizeValue = NULL;
|
CGPoint position = CGPointZero;
|
CGSize size = CGSizeZero;
|
BOOL ok = NO;
|
|
if (AXUIElementCopyAttributeValue(element, kAXPositionAttribute, &positionValue) == kAXErrorSuccess &&
|
AXUIElementCopyAttributeValue(element, kAXSizeAttribute, &sizeValue) == kAXErrorSuccess &&
|
positionValue && sizeValue &&
|
CFGetTypeID(positionValue) == AXValueGetTypeID() &&
|
CFGetTypeID(sizeValue) == AXValueGetTypeID() &&
|
AXValueGetValue((AXValueRef)positionValue, kAXValueCGPointType, &position) &&
|
AXValueGetValue((AXValueRef)sizeValue, kAXValueCGSizeType, &size)) {
|
CGRect rect = CGRectMake(position.x, position.y, size.width, size.height);
|
*outRect = rectFromTopLeftOrRawCGRect(rect);
|
ok = rectLooksUsable(*outRect);
|
}
|
|
if (positionValue) CFRelease(positionValue);
|
if (sizeValue) CFRelease(sizeValue);
|
return ok;
|
}
|
|
static BOOL axCopyBoundsForSelectedText(AXUIElementRef element, NSRect* outRect) {
|
if (!element || !outRect) return NO;
|
|
CFTypeRef selectedRange = NULL;
|
CFTypeRef boundsValue = NULL;
|
CGRect bounds = CGRectZero;
|
BOOL ok = NO;
|
|
if (AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute, &selectedRange) == kAXErrorSuccess &&
|
selectedRange &&
|
AXUIElementCopyParameterizedAttributeValue(element, kAXBoundsForRangeParameterizedAttribute, selectedRange, &boundsValue) == kAXErrorSuccess &&
|
boundsValue &&
|
CFGetTypeID(boundsValue) == AXValueGetTypeID() &&
|
AXValueGetValue((AXValueRef)boundsValue, kAXValueCGRectType, &bounds)) {
|
*outRect = rectFromTopLeftOrRawCGRect(bounds);
|
ok = rectLooksUsable(*outRect);
|
}
|
|
if (selectedRange) CFRelease(selectedRange);
|
if (boundsValue) CFRelease(boundsValue);
|
return ok;
|
}
|
|
static BOOL copyFocusedInputRect(NSRect* outRect) {
|
if (!outRect) return NO;
|
|
AXUIElementRef systemWide = AXUIElementCreateSystemWide();
|
if (!systemWide) return NO;
|
|
CFTypeRef focusedValue = NULL;
|
BOOL ok = NO;
|
if (AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute, &focusedValue) == kAXErrorSuccess &&
|
focusedValue &&
|
CFGetTypeID(focusedValue) == AXUIElementGetTypeID()) {
|
AXUIElementRef focused = (AXUIElementRef)focusedValue;
|
CFTypeRef windowValue = NULL;
|
if (AXUIElementCopyAttributeValue(focused, kAXWindowAttribute, &windowValue) == kAXErrorSuccess &&
|
windowValue &&
|
CFGetTypeID(windowValue) == AXUIElementGetTypeID()) {
|
ok = axCopyRectFromElement((AXUIElementRef)windowValue, outRect);
|
}
|
if (!ok) ok = axCopyBoundsForSelectedText(focused, outRect);
|
if (!ok) ok = axCopyRectFromElement(focused, outRect);
|
if (windowValue) CFRelease(windowValue);
|
}
|
|
if (focusedValue) CFRelease(focusedValue);
|
CFRelease(systemWide);
|
return ok;
|
}
|
|
static BOOL copyFrontmostWindowRect(NSRect* outRect) {
|
if (!outRect) return NO;
|
NSRunningApplication* app = [[NSWorkspace sharedWorkspace] frontmostApplication];
|
if (!app) return NO;
|
pid_t pid = [app processIdentifier];
|
|
CFArrayRef windowInfo = CGWindowListCopyWindowInfo(
|
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
|
kCGNullWindowID
|
);
|
if (!windowInfo) return NO;
|
|
BOOL ok = NO;
|
NSArray* windows = (NSArray*)windowInfo;
|
for (NSDictionary* info in windows) {
|
NSNumber* ownerPID = [info objectForKey:(id)kCGWindowOwnerPID];
|
if (!ownerPID || [ownerPID intValue] != pid) continue;
|
|
NSNumber* layer = [info objectForKey:(id)kCGWindowLayer];
|
if (layer && [layer intValue] != 0) continue;
|
|
NSNumber* alpha = [info objectForKey:(id)kCGWindowAlpha];
|
if (alpha && [alpha floatValue] <= 0.0f) continue;
|
|
CFDictionaryRef boundsDict = (CFDictionaryRef)[info objectForKey:(id)kCGWindowBounds];
|
CGRect bounds = CGRectZero;
|
if (!boundsDict || !CGRectMakeWithDictionaryRepresentation(boundsDict, &bounds)) continue;
|
if (bounds.size.width < 80.0f || bounds.size.height < 40.0f) continue;
|
|
*outRect = rectFromTopLeftOrRawCGRect(bounds);
|
ok = rectLooksUsable(*outRect);
|
if (ok) break;
|
}
|
|
CFRelease(windowInfo);
|
return ok;
|
}
|
|
static NSScreen* screenForPoint(NSPoint point) {
|
for (NSScreen* candidate in [NSScreen screens]) {
|
if (NSPointInRect(point, candidate.frame)) return candidate;
|
}
|
return nil;
|
}
|
|
static NSScreen* screenForRect(NSRect rect) {
|
if (!rectLooksUsable(rect)) return nil;
|
|
NSScreen* screen = screenForPoint(NSMakePoint(NSMidX(rect), NSMidY(rect)));
|
if (screen) return screen;
|
|
NSScreen* best = nil;
|
CGFloat bestArea = 0.0f;
|
for (NSScreen* candidate in [NSScreen screens]) {
|
CGFloat area = rectIntersectionArea(rect, candidate.frame);
|
if (!best || area > bestArea) {
|
best = candidate;
|
bestArea = area;
|
}
|
}
|
return best;
|
}
|
|
static CGFloat clampFloat(CGFloat value, CGFloat minValue, CGFloat maxValue) {
|
if (maxValue < minValue) return minValue;
|
if (value < minValue) return minValue;
|
if (value > maxValue) return maxValue;
|
return value;
|
}
|
|
static NSPoint overlayOriginForTarget(NSScreen* screen, NSRect targetRect, BOOL hasTarget) {
|
NSRect wa = screen.visibleFrame;
|
CGFloat targetMidX = hasTarget ? NSMidX(targetRect) : NSMidX(wa);
|
CGFloat x = targetMidX - CAP_W * 0.5f;
|
if (CAP_W < wa.size.width) {
|
x = clampFloat(x, wa.origin.x, wa.origin.x + wa.size.width - CAP_W);
|
} else {
|
x = wa.origin.x + (wa.size.width - CAP_W) * 0.5f;
|
}
|
|
CGFloat y = wa.origin.y + 100.0f;
|
if (hasTarget && targetRect.size.height > CAP_H + 80.0f) {
|
CGFloat inset = fmin(120.0f, fmax(32.0f, targetRect.size.height * 0.18f));
|
y = targetRect.origin.y + inset;
|
}
|
if (CAP_H < wa.size.height) {
|
y = clampFloat(y, wa.origin.y, wa.origin.y + wa.size.height - CAP_H);
|
} else {
|
y = wa.origin.y + (wa.size.height - CAP_H) * 0.5f;
|
}
|
return NSMakePoint((int)x, (int)y);
|
}
|
|
// ---- Custom NSView for CG drawing ----
|
@interface PrivateVoiceOverlayView : NSView
|
@end
|
|
@implementation PrivateVoiceOverlayView
|
|
- (void)drawRect:(NSRect)dirtyRect {
|
CGContextRef ctx = [[NSGraphicsContext currentContext] CGContext];
|
float w = self.bounds.size.width;
|
float h = self.bounds.size.height;
|
|
CGContextClearRect(ctx, self.bounds);
|
|
// Measure text
|
float textW = 0;
|
NSString* text = nil;
|
NSDictionary* textAttrs = nil;
|
if (g_state.text[0] != '\0') {
|
text = [NSString stringWithUTF8String:g_state.text];
|
textAttrs = @{
|
NSFontAttributeName: [NSFont systemFontOfSize:TXT_SZ weight:NSFontWeightRegular],
|
NSForegroundColorAttributeName: [NSColor colorWithRed:1 green:1 blue:1 alpha:0.82f]
|
};
|
textW = [text sizeWithAttributes:textAttrs].width;
|
}
|
|
// Borderless light field: no stroke, no hard capsule edge.
|
float midY = h * 0.5f;
|
float t = g_state.animTime;
|
CGContextSetBlendMode(ctx, kCGBlendModePlusLighter);
|
drawEllipticalGlow(ctx, w * 0.34f, midY - 3, w * 0.34f, h * 0.58f, 0.13f, 0.72f, 1.0f, 0.34f);
|
drawEllipticalGlow(ctx, w * 0.52f, midY + 2, w * 0.31f, h * 0.48f, 0.46f, 0.32f, 1.0f, 0.25f);
|
drawEllipticalGlow(ctx, w * 0.70f, midY - 2, w * 0.30f, h * 0.56f, 1.0f, 0.30f, 0.80f, 0.30f);
|
drawEllipticalGlow(ctx, w * 0.50f, midY, w * 0.52f, h * 0.32f, 1.0f, 1.0f, 1.0f, 0.09f);
|
|
float barsW = N_BARS * BAR_W + (N_BARS - 1) * BAR_GAP;
|
float totalW = barsW;
|
if (textW > 0) totalW += TXT_GAP + textW;
|
float startX = (w - totalW) / 2;
|
if (startX < 86) startX = 86;
|
float barsEnd = startX + barsW;
|
float textX = barsEnd + TXT_GAP;
|
float waveStart = 54;
|
float waveEnd = textW > 0 ? textX + textW + 34 : w - 54;
|
if (waveEnd > w - 36) waveEnd = w - 36;
|
|
// Fine horizontal light waves flowing through the transparent field.
|
drawWavePath(ctx, waveStart, waveEnd, midY - 8.0f, 12.0f, t * 1.15f, 2.45f, 0.67f, 0.91f, 1.00f, 0.45f, 1.05f);
|
drawWavePath(ctx, waveStart + 18, waveEnd - 8, midY + 5.0f, 9.0f, -t * 1.00f + 1.9f, 2.05f, 1.00f, 0.80f, 0.98f, 0.38f, 0.95f);
|
drawWavePath(ctx, waveStart + 10, waveEnd - 24, midY + 14.0f, 6.5f, t * 0.82f + 3.1f, 2.75f, 0.92f, 1.00f, 1.00f, 0.24f, 0.75f);
|
drawWavePath(ctx, waveStart + 32, waveEnd - 28, midY - 19.0f, 7.5f, -t * 0.72f + 0.7f, 2.00f, 0.92f, 0.76f, 1.00f, 0.22f, 0.70f);
|
|
// Draw bars
|
static const float profiles[N_BARS] = {0.20f, 0.34f, 0.58f, 0.83f, 1.12f, 1.34f, 1.05f, 0.82f, 0.60f, 0.40f, 0.24f};
|
for (int i = 0; i < N_BARS; i++) {
|
float bx = startX + i * (BAR_W + BAR_GAP);
|
float bh = g_state.barHeights[i] * profiles[i];
|
if (bh < 7) bh = 7;
|
if (bh > 66) bh = 66;
|
float by = (h - bh) / 2;
|
|
CGRect barRect = CGRectMake(bx, by, BAR_W, bh);
|
CGPathRef barPath = CGPathCreateWithRoundedRect(barRect, BAR_R, BAR_R, NULL);
|
if (!barPath) continue;
|
float p = (float)i / (float)(N_BARS - 1);
|
float r, g, b;
|
spectralColor(p, &r, &g, &b);
|
applyStatusTint(&r, &g, &b, g_state.status == 3 || g_state.status == 4 ? 0.06f : 0.16f);
|
CGColorRef glow = CGColorCreateGenericRGB(r, g, b, 0.82f);
|
|
CGContextSaveGState(ctx);
|
if (glow) {
|
CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 14.0f, glow);
|
}
|
CGContextAddPath(ctx, barPath);
|
CGContextSetRGBFillColor(ctx, r, g, b, 0.88f);
|
CGContextFillPath(ctx);
|
CGContextRestoreGState(ctx);
|
|
CGContextAddPath(ctx, barPath);
|
CGContextSetRGBStrokeColor(ctx, 1, 1, 1, 0.52f);
|
CGContextSetLineWidth(ctx, 1.15f);
|
CGContextStrokePath(ctx);
|
if (glow) {
|
CGColorRelease(glow);
|
}
|
CGPathRelease(barPath);
|
}
|
|
// Small luminous beads echo the reference artwork without creating a border.
|
for (int i = 0; i < 7; i++) {
|
float p = (float)i / 6.0f;
|
float cx = mixf(startX - 46, barsEnd + 46, p);
|
float phase = t * 2.1f + p * 8.0f;
|
float radius = 2.0f + (sinf(phase) * 0.5f + 0.5f) * 2.0f;
|
float r, g, b;
|
spectralColor(p, &r, &g, &b);
|
applyStatusTint(&r, &g, &b, g_state.status == 3 || g_state.status == 4 ? 0.06f : 0.16f);
|
CGColorRef glow = CGColorCreateGenericRGB(r, g, b, 0.68f);
|
CGContextSaveGState(ctx);
|
if (glow) {
|
CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 8.0f, glow);
|
}
|
CGContextSetRGBFillColor(ctx, r, g, b, 0.72f);
|
CGContextFillEllipseInRect(ctx, CGRectMake(cx - radius, midY - radius, radius * 2, radius * 2));
|
CGContextRestoreGState(ctx);
|
if (glow) {
|
CGColorRelease(glow);
|
}
|
}
|
|
// Draw text
|
if (text && textAttrs) {
|
NSSize sz = [text sizeWithAttributes:textAttrs];
|
float ty = (h - sz.height) / 2;
|
float tr = 0.63f, tg = 0.88f, tb = 1.0f;
|
applyStatusTint(&tr, &tg, &tb, g_state.status == 3 || g_state.status == 4 ? 0.06f : 0.16f);
|
CGColorRef textGlow = CGColorCreateGenericRGB(tr, tg, tb, 0.58f);
|
CGContextSaveGState(ctx);
|
if (textGlow) {
|
CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 10.0f, textGlow);
|
}
|
[text drawAtPoint:NSMakePoint(textX, ty) withAttributes:textAttrs];
|
CGContextRestoreGState(ctx);
|
if (textGlow) {
|
CGColorRelease(textGlow);
|
}
|
}
|
|
CGContextSetBlendMode(ctx, kCGBlendModeNormal);
|
}
|
|
@end
|
|
@interface PrivateVoiceDragView : NSView {
|
NSPoint _dragStartMouse;
|
NSPoint _dragStartOrigin;
|
BOOL _draggingWindow;
|
}
|
@end
|
|
@implementation PrivateVoiceDragView
|
|
- (BOOL)acceptsFirstMouse:(NSEvent*)event {
|
return YES;
|
}
|
|
- (void)mouseDown:(NSEvent*)event {
|
_dragStartMouse = [NSEvent mouseLocation];
|
_dragStartOrigin = g_window ? g_window.frame.origin : self.window.frame.origin;
|
_draggingWindow = YES;
|
}
|
|
- (void)mouseDragged:(NSEvent*)event {
|
if (!_draggingWindow || !self.window) return;
|
NSPoint currentMouse = [NSEvent mouseLocation];
|
NSPoint nextOrigin = NSMakePoint(
|
_dragStartOrigin.x + currentMouse.x - _dragStartMouse.x,
|
_dragStartOrigin.y + currentMouse.y - _dragStartMouse.y
|
);
|
positionOverlayWindows(nextOrigin);
|
}
|
|
- (void)mouseUp:(NSEvent*)event {
|
if (_draggingWindow && g_window) {
|
storeDragPositionFromVisualOrigin(g_window.frame.origin);
|
}
|
_draggingWindow = NO;
|
}
|
|
@end
|
|
// ---- Custom NSPanel for non-activating overlay windows ----
|
@interface PrivateVoiceOverlayWindow : NSPanel
|
@end
|
|
@implementation PrivateVoiceOverlayWindow
|
|
- (BOOL)canBecomeKeyWindow { return NO; }
|
- (BOOL)canBecomeMainWindow { return NO; }
|
|
@end
|
|
// ---- Timer target ----
|
static PrivateVoiceOverlayView* g_view = nil;
|
static NSTimer* g_timer = nil;
|
|
static void applyOverlayWindowPolicy(NSWindow* window) {
|
if (!window) return;
|
[window setLevel:CGWindowLevelForKey(kCGStatusWindowLevelKey)];
|
[window setCollectionBehavior:
|
NSWindowCollectionBehaviorCanJoinAllSpaces |
|
NSWindowCollectionBehaviorStationary |
|
NSWindowCollectionBehaviorFullScreenAuxiliary |
|
NSWindowCollectionBehaviorTransient |
|
NSWindowCollectionBehaviorIgnoresCycle];
|
[window setCanHide:NO];
|
if ([window isKindOfClass:[NSPanel class]]) {
|
[(NSPanel*)window setHidesOnDeactivate:NO];
|
}
|
}
|
|
static void animateBars(void) {
|
g_state.animTime += 0.033f;
|
float t = g_state.animTime;
|
|
switch (g_state.status) {
|
case 1: // loading
|
for (int i = 0; i < N_BARS; i++) {
|
float pulse = sinf(t * 2 + i * 0.3f) * 0.5f + 0.5f;
|
g_state.barHeights[i] = 10 + pulse * 20;
|
}
|
break;
|
case 2: // ready
|
case 6: // done
|
for (int i = 0; i < N_BARS; i++)
|
g_state.barHeights[i] = g_state.barHeights[i] * 0.85f + 14 * 0.15f;
|
break;
|
case 3: // recording
|
case 4: { // freetalking
|
int hasReal = 0;
|
for (int i = 0; i < N_BARS; i++)
|
if (g_state.volumes[i] > 0.01f) { hasReal = 1; break; }
|
for (int i = 0; i < N_BARS; i++) {
|
float target;
|
if (hasReal) {
|
target = 8 + g_state.volumes[i] * 32;
|
} else {
|
float wave = sinf(t * 4 + i * 0.8f) * 0.5f + 0.5f;
|
float rnd = sinf(t * 7 + i * 1.5f) * 0.3f;
|
target = 10 + (wave + rnd) * 25;
|
}
|
g_state.barHeights[i] = g_state.barHeights[i] * 0.6f + target * 0.4f;
|
}
|
break;
|
}
|
case 5: // processing
|
for (int i = 0; i < N_BARS; i++) {
|
float wave = sinf(t * 3 + i * 0.6f) * 0.5f + 0.5f;
|
g_state.barHeights[i] = 12 + wave * 18;
|
}
|
break;
|
default:
|
for (int i = 0; i < N_BARS; i++)
|
g_state.barHeights[i] = g_state.barHeights[i] * 0.85f + 14 * 0.15f;
|
break;
|
}
|
}
|
|
@interface OverlayTimerTarget : NSObject
|
- (void)tick:(NSTimer*)timer;
|
@end
|
|
@implementation OverlayTimerTarget
|
|
- (void)tick:(NSTimer*)timer {
|
if (!g_window) return;
|
|
// Fade
|
if (g_state.fadeProgress < g_state.fadeTarget) {
|
g_state.fadeProgress += 0.16f;
|
if (g_state.fadeProgress > 1.0f) g_state.fadeProgress = 1.0f;
|
} else if (g_state.fadeProgress > g_state.fadeTarget) {
|
g_state.fadeProgress -= 0.22f;
|
if (g_state.fadeProgress < 0) g_state.fadeProgress = 0;
|
}
|
|
float eased = g_state.fadeProgress * (2 - g_state.fadeProgress);
|
[g_window setAlphaValue:eased];
|
|
if (g_state.fadeProgress <= 0 && g_state.fadeTarget == 0) {
|
[g_window orderOut:nil];
|
if (g_dragWindow) [g_dragWindow orderOut:nil];
|
return;
|
}
|
|
if (g_state.fadeTarget > 0) {
|
if (![g_window isVisible]) {
|
applyOverlayWindowPolicy(g_window);
|
[g_window orderFrontRegardless];
|
}
|
if (g_dragWindow && ![g_dragWindow isVisible]) {
|
applyOverlayWindowPolicy(g_dragWindow);
|
[g_dragWindow orderFrontRegardless];
|
}
|
}
|
|
// Reposition
|
if (g_state.needsPosition) {
|
g_state.needsPosition = 0;
|
positionOverlayWindows(visualOriginFromTopLeft(g_state.posX, g_state.posY));
|
}
|
|
animateBars();
|
[g_view setNeedsDisplay:YES];
|
}
|
|
@end
|
|
static OverlayTimerTarget* g_timerTarget = nil;
|
|
// ---- C API for Go ----
|
|
static void doCreateOverlay(void* ctx) {
|
(void)ctx;
|
|
PrivateVoiceOverlayWindow* window = [[PrivateVoiceOverlayWindow alloc]
|
initWithContentRect:NSMakeRect(0, 0, CAP_W, CAP_H)
|
styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
|
backing:NSBackingStoreBuffered
|
defer:NO];
|
|
[window setOpaque:NO];
|
[window setBackgroundColor:[NSColor clearColor]];
|
applyOverlayWindowPolicy(window);
|
[window setIgnoresMouseEvents:YES];
|
[window setMovableByWindowBackground:NO];
|
[window setHasShadow:NO];
|
[window setAlphaValue:0];
|
|
PrivateVoiceOverlayView* view = [[PrivateVoiceOverlayView alloc]
|
initWithFrame:NSMakeRect(0, 0, CAP_W, CAP_H)];
|
[window setContentView:view];
|
|
g_window = window;
|
g_view = view;
|
|
PrivateVoiceOverlayWindow* dragWindow = [[PrivateVoiceOverlayWindow alloc]
|
initWithContentRect:NSMakeRect(0, 0, DRAG_W, DRAG_H)
|
styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
|
backing:NSBackingStoreBuffered
|
defer:NO];
|
|
[dragWindow setOpaque:NO];
|
[dragWindow setBackgroundColor:[NSColor clearColor]];
|
applyOverlayWindowPolicy(dragWindow);
|
[dragWindow setIgnoresMouseEvents:NO];
|
[dragWindow setMovableByWindowBackground:NO];
|
[dragWindow setHasShadow:NO];
|
[dragWindow setAlphaValue:1.0];
|
|
PrivateVoiceDragView* dragView = [[PrivateVoiceDragView alloc]
|
initWithFrame:NSMakeRect(0, 0, DRAG_W, DRAG_H)];
|
[dragWindow setContentView:dragView];
|
g_dragWindow = dragWindow;
|
|
g_timerTarget = [[OverlayTimerTarget alloc] init];
|
g_timer = [NSTimer timerWithTimeInterval:0.033
|
target:g_timerTarget
|
selector:@selector(tick:)
|
userInfo:nil
|
repeats:YES];
|
[[NSRunLoop currentRunLoop] addTimer:g_timer forMode:NSRunLoopCommonModes];
|
}
|
|
static void overlayCreate(void) {
|
dispatch_async_f(dispatch_get_main_queue(), NULL, doCreateOverlay);
|
}
|
|
static void doAutoPosition(void* ctx) {
|
(void)ctx;
|
if (!g_window) return;
|
|
NSRect targetRect = NSZeroRect;
|
BOOL hasTarget = copyFocusedInputRect(&targetRect);
|
if (!hasTarget) hasTarget = copyFrontmostWindowRect(&targetRect);
|
|
NSScreen* screen = hasTarget ? screenForRect(targetRect) : nil;
|
if (!screen) {
|
screen = screenForPoint([NSEvent mouseLocation]);
|
}
|
if (!screen) screen = [NSScreen mainScreen];
|
if (!screen) return;
|
|
NSPoint origin = overlayOriginForTarget(screen, targetRect, hasTarget);
|
positionOverlayWindows(origin);
|
NSPoint topLeft = topLeftFromVisualOrigin(origin);
|
g_state.posX = (int)topLeft.x;
|
g_state.posY = (int)topLeft.y;
|
g_state.hasPosition = 1;
|
}
|
|
static void doBringOverlayForward(void* ctx) {
|
(void)ctx;
|
if (!g_window) return;
|
applyOverlayWindowPolicy(g_window);
|
[g_window orderFrontRegardless];
|
if (g_dragWindow) {
|
applyOverlayWindowPolicy(g_dragWindow);
|
[g_dragWindow orderFrontRegardless];
|
}
|
}
|
|
static void overlayShow(void) {
|
g_state.fadeTarget = 1.0f;
|
if (!g_state.hasPosition) {
|
dispatch_async_f(dispatch_get_main_queue(), NULL, doAutoPosition);
|
}
|
dispatch_async_f(dispatch_get_main_queue(), NULL, doBringOverlayForward);
|
}
|
|
static void overlayAutoPosition(void) {
|
g_state.hasPosition = 0;
|
dispatch_async_f(dispatch_get_main_queue(), NULL, doAutoPosition);
|
}
|
|
static void doHideDragOverlay(void* ctx) {
|
(void)ctx;
|
if (g_state.fadeTarget == 0.0f && g_dragWindow) {
|
[g_dragWindow orderOut:nil];
|
}
|
}
|
|
static void overlayHide(void) {
|
g_state.fadeTarget = 0.0f;
|
dispatch_async_f(dispatch_get_main_queue(), NULL, doHideDragOverlay);
|
}
|
|
static void overlaySetStatus(int status, const char* text, float r, float g, float b) {
|
g_state.status = status;
|
g_state.barR = r;
|
g_state.barG = g;
|
g_state.barB = b;
|
if (text) {
|
strncpy(g_state.text, text, sizeof(g_state.text) - 1);
|
g_state.text[sizeof(g_state.text) - 1] = '\0';
|
} else {
|
g_state.text[0] = '\0';
|
}
|
}
|
|
static void overlaySetVolume(float vol) {
|
for (int i = 0; i < N_BARS - 1; i++)
|
g_state.volumes[i] = g_state.volumes[i + 1];
|
g_state.volumes[N_BARS - 1] = vol;
|
}
|
|
static void overlaySetPosition(int x, int y) {
|
g_state.posX = x;
|
g_state.posY = y;
|
g_state.hasPosition = 1;
|
g_state.needsPosition = 1;
|
}
|
|
static void overlayGetPosition(int* x, int* y) {
|
*x = g_state.posX;
|
*y = g_state.posY;
|
}
|
|
static int overlayCheckDrag(int* x, int* y) {
|
if (g_state.dragEnded) {
|
g_state.dragEnded = 0;
|
*x = g_state.dragX;
|
*y = g_state.dragY;
|
return 1;
|
}
|
return 0;
|
}
|
|
static void doCloseOverlay(void* ctx) {
|
(void)ctx;
|
if (g_timer) {
|
[g_timer invalidate];
|
g_timer = nil;
|
}
|
if (g_window) {
|
[g_window close];
|
g_window = nil;
|
}
|
if (g_dragWindow) {
|
[g_dragWindow close];
|
g_dragWindow = nil;
|
}
|
g_view = nil;
|
g_timerTarget = nil;
|
}
|
|
static void overlayClose(void) {
|
dispatch_async_f(dispatch_get_main_queue(), NULL, doCloseOverlay);
|
}
|
*/
|
import "C"
|
|
import (
|
"time"
|
"unsafe"
|
)
|
|
const (
|
darwinOverlayWidth = 620
|
darwinOverlayHeight = 112
|
)
|
|
// darwinOverlay uses a native NSWindow + Core Graphics to render
|
// the floating indicator, matching the Windows GDI+ implementation.
|
type darwinOverlay struct {
|
dragCb func(int, int)
|
created bool
|
done chan struct{}
|
}
|
|
func New() Overlay {
|
return &darwinOverlay{
|
done: make(chan struct{}),
|
}
|
}
|
|
func (o *darwinOverlay) ensureCreated() {
|
if !o.created {
|
C.overlayCreate()
|
o.created = true
|
time.Sleep(50 * time.Millisecond)
|
go o.pollDrag()
|
}
|
}
|
|
func (o *darwinOverlay) Show() {
|
o.ensureCreated()
|
C.overlayShow()
|
}
|
|
func (o *darwinOverlay) Hide() {
|
C.overlayHide()
|
}
|
|
func (o *darwinOverlay) SetStatus(status Status, text string) {
|
r, g, b := statusColorRGB(status)
|
cText := C.CString(text)
|
defer C.free(unsafe.Pointer(cText))
|
C.overlaySetStatus(statusToInt(status), cText, C.float(r), C.float(g), C.float(b))
|
}
|
|
func (o *darwinOverlay) SetVolume(vol float64) {
|
C.overlaySetVolume(C.float(vol))
|
}
|
|
func (o *darwinOverlay) SetPosition(x, y int) {
|
C.overlaySetPosition(C.int(x), C.int(y))
|
}
|
|
func (o *darwinOverlay) AutoPosition() {
|
o.ensureCreated()
|
C.overlayAutoPosition()
|
}
|
|
func (o *darwinOverlay) GetPosition() (int, int) {
|
var x, y C.int
|
C.overlayGetPosition(&x, &y)
|
return int(x), int(y)
|
}
|
|
func (o *darwinOverlay) Size() (int, int) {
|
return darwinOverlayWidth, darwinOverlayHeight
|
}
|
|
func (o *darwinOverlay) OnDragged(fn func(int, int)) {
|
o.dragCb = fn
|
}
|
|
func (o *darwinOverlay) Close() {
|
select {
|
case <-o.done:
|
default:
|
close(o.done)
|
}
|
C.overlayClose()
|
}
|
|
func (o *darwinOverlay) pollDrag() {
|
ticker := time.NewTicker(100 * time.Millisecond)
|
defer ticker.Stop()
|
for {
|
select {
|
case <-o.done:
|
return
|
case <-ticker.C:
|
var x, y C.int
|
if C.overlayCheckDrag(&x, &y) != 0 {
|
if o.dragCb != nil {
|
o.dragCb(int(x), int(y))
|
}
|
}
|
}
|
}
|
}
|
|
func statusToInt(s Status) C.int {
|
switch s {
|
case StatusLoading:
|
return 1
|
case StatusReady:
|
return 2
|
case StatusRecording:
|
return 3
|
case StatusFreetalking:
|
return 4
|
case StatusProcessing:
|
return 5
|
case StatusDone:
|
return 6
|
case StatusCancelled:
|
return 7
|
case StatusNoVoice:
|
return 8
|
case StatusNoContent:
|
return 9
|
case StatusError:
|
return 10
|
default:
|
return 0
|
}
|
}
|
|
func statusColorRGB(s Status) (float32, float32, float32) {
|
switch s {
|
case StatusLoading, StatusFreetalking:
|
return 0, 0.478, 1.0 // #007AFF
|
case StatusReady, StatusDone:
|
return 0.204, 0.780, 0.349 // #34C759
|
case StatusRecording, StatusError:
|
return 1.0, 0.231, 0.188 // #FF3B30
|
case StatusProcessing, StatusCancelled, StatusNoVoice, StatusNoContent:
|
return 1.0, 0.584, 0.0 // #FF9500
|
default:
|
return 0, 0.478, 1.0
|
}
|
}
|